Skip to content

sensoranalysis Offline Checks

Assertion programs that drive the calibration quality metrics with synthetic data, so the metrics can be developed and regression checked with no tag, no capture and no calibration. Every case has an answer known in advance.

They are plain main() programs with assert() and a failure count, not a registered test framework, because this repository has no test harness to register with. Build them explicitly:

They are off by default. Turn them on in the build tree you already have -- this only sets a cache variable, so it does not reconfigure from scratch -- and build the one you want:

BUILD=~/Build/tag-designs/software-vcpkg-release      # the macos-vcpkg preset
cmake -B $BUILD -DBUILD_SENSORANALYSIS_CHECKS=ON
cmake --build $BUILD --target magquality_check

Every binary lands in $BUILD/Release/bin (Debug for the debug preset, and whatever configuration a multi-configuration generator built on Windows). A check takes no arguments and exits non-zero when it fails:

$BUILD/Release/bin/magquality_check

To build all of them at once, name each target, or build everything:

cmake --build $BUILD --target accelcalibration_check gravityfit_check \
                              magquality_check magretention_check \
                              compass_core_parity_check capture_replay \
                              calibration_dump calibration_session_check

calibration_session_check

Requires calibcore::CalibrationSession to match a verbatim copy of the qtcalibrate CompassData code it replaced, bit for bit, on the two paths qtcalibrate's replay logs do not record: the low-pass filtered live compass (every sample of both fixtures, with and without an accelerometer offset in force) and constants loaded from a tag (the recomputed field and every calibrated point of a buffer each fixture filled). 48,702 values; the reference must not be edited to match the session.

On 2026-10-28 it was shown to bite: moving the filter's new-sample weight one float ulp from 0.6 failed 25,022 of them.

calibration_dump

Not a check: it compares nothing itself. It drives Directions, MagQuality, MagRetention, GravityFit and AccelCalibration through their Qt API over both qtcalibrate capture fixtures and seeded synthetic spheres, and prints every result as a hexadecimal float (%a), so two runs agree as text exactly when they agree to the bit. Use it for a change that must not alter results: build it on the code before, save the output, build it on the code after, and diff.

$BUILD/Release/bin/calibration_dump > before.txt     # old code
$BUILD/Release/bin/calibration_dump > after.txt      # new code
diff before.txt after.txt                            # must be empty

The magnetometer samples are calibrated with each fixture's stored calibration and given CompassProcessor's dip, as qtcalibrate feeds MagQuality; MagRetention is asked about windows of the buffer with and without probation, the outlier test and the patch floor; AccelCalibration is read every tenth sample, which also tightens its gate as qtcalibrate's tick does. The synthetic data comes from a fixed std::mt19937 stream through integer arithmetic only.

On 2026-10-28 it showed the move of these classes onto the Qt-free core (core/*_core.*) changed nothing: 4,913 lines, identical before and after, on macOS arm64 in a Release build. qtcalibrate's own replay logs of both fixtures (--replay-capture ... --replay-exit --log-file), which also cover its retention decisions and seeded fallback, were byte-identical too. The same replay logs, before and after, showed moving the magcal solver into the core and CompassData onto CalibrationSession changed nothing either.

compass_core_parity_check

Requires the Qt-free compass core (core/compass_core.* on core/vecmath.h) to be bit-identical to the QVector3D/QQuaternion code it replaced. The program carries a verbatim copy of that Qt code as the reference -- do not edit it to match the core -- and compares the quaternion's four parts, dip, field, acceleration magnitude, the calibrated magnetometer vector and the success flag.

Group What it pins down
captures Every sample with an accelerometer in both qtcalibrate capture fixtures, with the capture's own calibration and with none.
random 200,000 seeded sensor pairs under random hard and soft iron, covering both accelerometer branches and both magnetometer branches.
edges Zero vectors (refused by both), vectors exactly at, within and just beyond 1e-5 of unit length -- Qt's vector normalize() leaves the first two alone -- and vectors small enough that normalization is skipped.
calibration JSON 20,000 random calibrations written by protobuf as the download path writes them (zeros omitted, accelerometer sometimes absent), the fixture's stored row with its retired b field, and hand-written oddities, each read three ways: the Qt reader as it stood, the Qt reader on calibrationFromLookup(), and the Qt-free calibrationFromJson(). All three must agree to the bit.
angles 100,000 rounds of random, scaled, tiny and near-gimbal-lock quaternions, solved orientations and axis-angle rotations: sensormath::toEulerAngles() and fromAxisAndAngle() against Qt's own compiled QQuaternion methods, compasscore::displayAngles() against sensorui's compassAnglesFromQuaternion() as it stood, and qtcalibrate's tag-model composition qz * qy * qx.

On 2026-10-20 it was shown to bite: making vector normalize() always divide, as glm and Eigen do, failed 12 comparisons, all in the edge group; writing the quaternion product the textbook way failed 1.4 million; reading the calibration through float, as the typed protobuf message would, failed 17,451. The angles group was added on 2026-10-28, when the display conversion moved into the core; computing the yaw's 360 - z in double rather than float failed 133,605 of its comparisons.

The negative-zero edge case is why calibrationFromJson() folds -0 to +0: Qt's reader does, protobuf's does not.

The reference resolves its unqualified sqrt and atan2 calls as macOS's libc++ does, through two using declarations above it. GNU libstdc++ would otherwise pick the double C functions there, and the old Qt code did exactly that on Linux: against the reference as Linux compiled it, the core differs by a few float ulps (at most 3.6e-7 in a quaternion part on Debian 13). With the declarations the check is bit-identical on Debian 13 x86_64 in any build and on arm64 in a Release build (checked 2026-10-20). One case still differs: an unoptimised build on arm64, where Qt's own library, compiled optimised, fuses multiply-adds that the unoptimised reference does not -- up to 1.2e-5 there, so run the check optimised on arm64.

--report prints, per output, how many comparisons differ, how many produced NaN on one side only, and the largest difference between finite values, instead of failing. That is the measurement to make when vecmath.h moves onto glm or Eigen: the swap will not be bit-identical, and the report says by how much.

capture_replay

Not a check: a tool. It replays a saved qtcalibrate capture straight through the library and prints what the calibration code makes of it, sample by sample.

REPLAY=$BUILD/Release/bin/capture_replay
FIXTURE=host/docs/fixtures/qtcalibrate/qtcalibrate-samples-20261010-163303.json

$REPLAY $FIXTURE
$REPLAY $FIXTURE --csv > trace.csv          # one row per tick, for plotting
$REPLAY $FIXTURE --patches 64 --every 500

A fixture path relative to the repository root resolves wherever the tool is run from: the source root is compiled in at configure time, the same way qtcalibrate resolves --replay-capture. An absolute path works too. Output redirection is ordinary shell, so trace.csv lands in the current directory.

It reports the accelerometer offset as it develops, how far it moves over the second half of the sweep, patch coverage and evenness, and the robust inclination spread with and without the offset applied -- the last computed through CompassProcessor, the same routine qtcalibrate uses, and gated the same way MagQuality gates it.

It also breaks the inclination down by how fast the tag was turning, estimated from the angle between consecutive magnetometer directions since no target has a gyroscope, and says what a gate at each speed would be worth. A band's own spread is not that number: the pooled statistic is set by the bulk, so discarding a small fast tail moves it much less than the tail's spread suggests. That distinction is what decided against a rotation-rate gate.

The point is the questions a single end-of-run number cannot answer: does the estimate settle or wander, when does a metric stop improving, did a change move anything. Asking them through qtcalibrate needs the Qt build, a window and a run in real time; this needs a fixture and a second, which makes a design question cheap enough to actually test. The sliding-window fit that made the offset wander 13 mg was found this way.

On the committed 2425-sample fixture it reports an inclination spread of 3.61 degrees uncorrected and 2.47 with the offset removed, against the 2.58 qtcalibrate logs for the same capture -- the small difference being that qtcalibrate measures over its retained 650 samples rather than all of them.

accelcalibration_check

Group What it pins down
offset recovery A planted 77 mg offset comes back to within 3 mg from a full sweep, with the radius at one g and the residual reporting the scatter that was planted rather than zero.
keeps everything However long the sweep, every accepted reading contributes; there is no buffer to overflow and nothing is discarded.
even occupancy A thousand readings in one direction against two thousand over the sphere do not invent an offset: each occupied patch carries the same weight however long the operator lingered in it.
settling With 30 mg of noise the offset moves under 3 mg over the second half of a sweep. The rule this replaced kept the newest readings per patch, which made the fit a sliding window that wandered 13 mg peak to peak on the reference capture -- 0.76 degrees of tilt.
motion rejection Readings at 1.8 and 0.2 g never reach the buffer and are counted as gated.
refusal A yaw-only sweep is refused on patch count; a single-arc sweep occupies 14 of 32 patches and is refused on evenness instead. The second case is why both preconditions exist -- a patch count alone calls that sweep broad.
order independence Fed forward and backward the two fits agree to rounding, which they must: the fit is a function of sums.

Validated against the committed 2425-sample fixture as well as synthetic data: it settles at 74.5 mg with 1.3 mg of movement over the second half of the sweep, against the 74.8 mg a per-patch-weighted fit over that whole capture gives offline.

gravityfit_check

Group What it pins down
offset recovery A planted 77 mg offset -- the one measured on a real tag -- comes back to within a milli-g, the fitted radius is one g, and an unbiased sensor yields no offset rather than a small invented one.
motion rejection A sweep with a quarter of its samples taken while the tag was thrown about still lands within five milli-g, and the second pass is never worse than the first.
refusal Too few samples, a tag held in one attitude throughout, and a sphere nowhere near one g are all refused. The middle case matters most: a single attitude determines no sphere, and inventing a centre there would write a fabricated offset from a sweep that never happened.

magquality_check

Group What it pins down
patch lattice Centres are unit vectors, each centre falls in its own patch, and a dense uniform sample set fills the busiest and emptiest patch to within 1.5x -- so a patch count means what it says.
coverage and isotropy Uniform data reaches every patch with isotropy near one. Coplanar data -- a tag spun flat without being tipped -- still occupies many patches, so only the isotropy catches it.
dip consistency A constant inclination comes back with no spread; a known 2 degree scatter comes back as about 2 degrees, since the MAD is scaled to agree with a standard deviation.
acceleration gate Samples outside the gate are excluded from the dip statistics and cannot drag the inclination, while still counting toward coverage. That split is the point of the gate.
attitude diversity Sweeping gravity within one magnetometer patch raises diversity above one; holding one attitude gives exactly one. Without an accelerometer, the accelerometer metrics report nothing rather than guessing.
fit residual A clean sphere has no fit error; one percent of radial scatter reads as one percent on both the RMS and the robust spread. They part company when a hundredth of the samples are gross, where the RMS reads 5.07 percent against the MAD's 0.99 -- which is why both are reported.
residual hard iron A planted 2 unit offset comes back as 1.97, a dwell of 6000 readings in one direction invents none, and a dwell laid on top of a real offset does not hide it.

magretention_check

Group What it pins down
leverage The sample contributing least to the fit is the one chosen, and the hat-matrix trace identity holds: the leverages sum to the parameter count.
outliers A planted bad magnitude is evicted as an outlier rather than as uninformative, and with the rule off leverage alone never removes it -- it is a high-leverage sample, not a redundant one.
not outliers A lone sample in an otherwise empty direction, at an honest radius, survives. Being the only reading in a direction is not evidence that it is wrong, and under Cook's distance it was accused precisely because it was informative.
threshold units A 3 sigma residual is kept and a 6 sigma one is evicted, so the configured threshold means what it says.
guards Probation protects a newly added sample, and no eviction ever empties an occupied patch.

The dip and attitude cases are the ones worth keeping honest: they are the metrics the solver does not optimise, which is what makes them worth having.