DataProcessing Post-Processing Application Design
dataprocessing (host/commandline/dataprocessing.cc) copies a SQLite log to
a new file and adds durable derived tables, stream metadata, and a
ProcessingRun provenance row to the copy. It has three processors: two for
CompassTag, compass-calibrated and compass-orientation, and one for IMUTag,
imu-compass-orientation. The input file is never modified. Usage is in the command-line README;
open work is in TODO.md.
Why It Exists
SensorViz derives calibrated compass streams and orientation while a log is
being viewed, but those results are session-local. Notebooks, batch scripts and
downstream tools see only the raw SQLite log. dataprocessing materializes the
derived data so it travels with the file.
The application boundary is:
sensoranalysis: math and calibration/orientation data types
qtcalibrate: live sample collection and calibration solving UI
DataProcessing: batch SQLite copy, processor configuration, durable outputs
SensorViz: visualization and interactive display choices
dataprocessing uses no Qt. It links sensoranalysis_core -- the Qt-free
compass core -- through sensoranalysis_core_json, plus SQLite, and borrows
two self-contained files from tagcore: the vendored cxxopts header for its
command line and Sha256 for the input hash. It is a plain executable,
scriptable and deterministic, built in any host build whether or not the Qt
applications are
(decision 0031).
The compass math is not forked. compasscore::calibrationFromJson() reads the
newest Calibration row and compasscore::solveOrientation() solves each pair;
SensorViz and qtcalibrate reach the same functions through
CompassCalibration and CompassProcessor, which are adapters over the core.
The core is bit-identical to the Qt code it replaced, which
compass_core_parity_check verifies, so a log processed here holds exactly
what SensorViz derives from the same rows. SQLite read/write stays in the
application, keeping sensoranalysis independent of the database format. The
magcal calibration solver stays in qtcalibrate.
Viewer preferences (plot colors, axis sides, visibility) are never processing configuration.
Command Line
| Option | Behavior |
|---|---|
-i, --input <path> |
Input SQLite log. Must differ from the output path. |
-o, --output <path> |
Output SQLite log. |
-p, --processor <id> |
Processor to run; repeat for several. |
--if-exists fail\|replace\|keep |
Conflict policy, default fail. |
--dry-run |
Validate inputs and print planned outputs without writing. |
--list-processors |
List processors and whether the input log has what each needs. |
--describe <id> |
Describe one processor. |
--print-summary |
Print a processing summary. |
An unknown --if-exists value exits with status 2.
--if-exists applies twice. For the output file: fail refuses an existing
file, replace deletes and re-copies it, and keep reuses it. For each
processor's output tables and stream ids inside the file: fail refuses when
any already exist, keep skips that processor, and replace drops the tables
and stream rows before rewriting them. The default is fail so a rerun cannot
silently hide a stale processing decision.
Processing Model
The input is copied byte for byte, the copy is opened read/write, and all
processor writes happen inside one BEGIN IMMEDIATE transaction that is
committed at the end.
| Processor | Inputs | Outputs |
|---|---|---|
compass-calibrated |
Compass table, latest Calibration row |
CompassCalibrated table and six record_column streams |
compass-orientation |
Compass table, latest Calibration row |
CompassOrientation table and its compass_orientation record set |
imu-compass-orientation |
ImuMag and ImuAccel tables, latest Calibration row |
ImuCompassOrientation table and its imu_compass_orientation record set |
The calibration used is the Calibration row with the greatest Epoch; its
Constants JSON must contain a magnetometer object. Every processor applies
it through CompassProcessor: the magnetometer constants, and the
accelerometer zero-g offset when present.
Inputs are loaded per tag family, and only for the processors requested, so an
IMUTag log is not rejected for lacking a Compass table.
Pairing IMUTag samples
IMUTag logs the magnetometer once per superframe of
IMUTAG_IMU_SAMPLES_PER_SUPERFRAME (10) IMU samples, so there is no one-to-one
accelerometer row to pair it with. An ImuMag row's SampleIndex is the first
IMU sample index of its superframe, and imu-compass-orientation pairs it with
the mean of the ImuAccel rows in the same segment whose SampleIndex lies in
[SampleIndex, SampleIndex + 10). The mean covers the span the magnetometer
value stands for and suppresses motion in the gravity estimate. Its centre is
about half a superframe after the row's ElapsedUs, which is the time the
output is written at.
A magnetometer row with no accelerometer rows in its superframe, or a pair the
solver cannot use (a zero vector), is counted and skipped rather than failing
the run; --dry-run and --print-summary report the counts. The output's
AccelSamples column records how many rows each mean used.
The result is only as good as the tag's axis alignment; see Magnetometer and Accelerometer Axes. Logs from firmware without its board's magnetometer mapping give orientation with a dip that wanders with attitude.
Output Tables
CompassCalibrated
Epoch INTEGER
ax REAL
ay REAL
az REAL
mx REAL
my REAL
mz REAL
CompassOrientation
Epoch INTEGER
dip REAL
field REAL
acceleration REAL
qw REAL
qx REAL
qy REAL
qz REAL
ImuCompassOrientation
SegmentId INTEGER
SampleIndex INTEGER
ElapsedUs INTEGER
dip REAL
field REAL
acceleration REAL
AccelSamples INTEGER
qw REAL
qx REAL
qy REAL
qz REAL
CompassCalibrated holds magnetometer values after hard-iron and soft-iron
correction, beside acceleration with the zero-g offset removed, so the record
set is self-contained.
ImuCompassOrientation carries the ImuMag row's SegmentId, SampleIndex
and ElapsedUs, so it lines up with the raw IMUTag tables, and the same
orientation columns as CompassOrientation. acceleration is the magnitude of
the superframe mean.
CompassOrientation holds magnetic-frame orientation as a quaternion
(qw, qx, qy, qz), not Euler angles and not a display heading. Euler angles
are a convention -- a rotation order and an axis labelling -- and libraries
disagree on it, so stored angles would tie the data to whichever library
computed them. Heading, pitch and roll, declination and battery-forward/backward
mounting all belong to downstream interpretation or to SensorViz and qtcalibrate
display state (see Downstream Heading Conversion
and decision 0030).
Stream Ids
compass_calibrated_ax
compass_calibrated_ay
compass_calibrated_az
compass_calibrated_mx
compass_calibrated_my
compass_calibrated_mz
compass_orientation_qw
compass_orientation_qx
compass_orientation_qy
compass_orientation_qz
compass_orientation_dip
compass_orientation_field
compass_orientation_acceleration
imu_compass_orientation_qw
imu_compass_orientation_qx
imu_compass_orientation_qy
imu_compass_orientation_qz
imu_compass_orientation_dip
imu_compass_orientation_field
imu_compass_orientation_acceleration
They use the existing streams catalog style, all as record_column entries:
the calibrated vectors in the compass_calibrated group, and each orientation
table's columns, quaternion included, in a group named after the table's
processor (compass_orientation, imu_compass_orientation). Orientation is a
record set rather than scalar streams because its quaternion is only meaningful
as four columns together; the consumer turns it into the angles it displays.
The IMUTag streams use ElapsedUs as their time column, like the raw IMUTag
streams. The ids do not collide with SensorViz's display stream ids. Raw
streams are never mutated or removed.
Processor version 1 also wrote yaw, pitch and roll columns, and
registered yaw, pitch, roll, dip, field and acceleration as scalar streams.
Version 2 still removes the retired yaw, pitch and roll ids when it replaces an
older output, so a reprocessed file never lists a stream whose column is gone.
Provenance
Every successful processor run inserts one row into ProcessingRun, created if
absent:
ProcessingRun
RunId INTEGER PRIMARY KEY
ToolName TEXT
ToolVersion TEXT
ProcessorId TEXT
ProcessorVersion INTEGER
CreatedUtc TEXT
InputFileName TEXT
InputSha256 TEXT
ConfigurationJson TEXT
SourceTablesJson TEXT
OutputTablesJson TEXT
Status TEXT
ConfigurationJson records the processor, algorithm_version (currently 2)
and the calibration source. For both orientation processors it also records the
frame, quaternion order and heading policy, and for imu-compass-orientation
an accel_pairing object naming the method (superframe_mean) and the
superframe size. An illustrative example (the epoch is a
placeholder):
{
"processor": "compass-orientation",
"algorithm_version": 2,
"calibration_source": {
"table": "Calibration",
"epoch": 1778983834
},
"orientation_frame": "magnetic-frame-nwu",
"quaternion_order": "wxyz",
"heading_policy": "not_materialized; downstream tools derive heading from the quaternion, then apply declination and mounting convention"
}
Processing configuration is not a SensorViz preference. It describes how the data was computed and travels with the output database.
Downstream Heading Conversion
The host displays use one Euler convention: Qt's toEulerAngles(), applied
where the angles are shown -- by compassAnglesFromQuaternion() in
host/libraries/sensorui/compass_angles.h for C++ displays, and by
MyCompass.qml on the quaternion it is given. A tool without Qt reproduces it
from the stored quaternion, normalized, as:
pitch = asin(2 (qw qx - qy qz))
roll = atan2(2 (qx qz + qw qy), 1 - 2 (qx^2 + qy^2))
z = atan2(2 (qx qy + qw qz), 1 - 2 (qx^2 + qz^2))
yaw = normalize_360(360 - z)
That is Qt's decomposition q = Ry * Rx * Rz, with the x angle shown as pitch,
the y angle as roll, and the z angle, reversed to read clockwise, as magnetic
yaw. It is not the aerospace Z-Y-X order, and the two give different yaw when
the tag is tilted about both horizontal axes. Near |pitch| = 90 Qt switches
to a gimbal-lock branch that these formulas do not reproduce. Checked against
Qt on 1011 CompassTag and IMUTag rows, they agree to 3e-4 degrees.
Tools that need a user-facing heading then apply:
magnetic_heading = normalize_360(yaw + declination_degrees)
display_heading = battery_forward
? magnetic_heading
: normalize_360(magnetic_heading + 180.0)
Where:
yawis the magnetic-frame yaw above, in degrees;declination_degreesis the downstream tool's chosen east-positive declination correction;battery_forwardis the downstream tool's selected physical mounting convention;normalize_360(x)maps any angle to[0, 360).
This keeps the processed output reusable: a notebook can choose local declination or mounting assumptions without rerunning calibration and orientation processing.
SensorViz And Processed Files
SensorViz builds every compass view from quaternions. For a processed file it
reads the compass_orientation or imu_compass_orientation record set and
uses the stored quaternions, ahead of re-deriving orientation from a
CompassTag's raw Compass table; for an unprocessed CompassTag log it derives
the quaternions itself with CompassProcessor. Heading, pitch and roll are
then computed from the quaternion in either case, so an IMUTag log -- for which
SensorViz derives no orientation itself -- gets the same compass streams and
panel once processed. File Info names the source. SensorViz does not read
ProcessingRun. The remaining open questions are in TODO.md.
Verification
There are no automated tests for dataprocessing. The checks to make by hand
or in a future test are listed in TODO.md.
The original step-by-step implementation plan is kept, as history, in proposals/dataprocessing-implementation-plan.md.