A Toolkit-Neutral Core for sensorviz
Status: phase A is built (13516ce2): sensorviz_core with a Qt-free
ViewModel, PlotSpec/buildPlotSpec() and StreamMeta, and menus that show
the state instead of holding it. Phase A as built records
where it differs from the sketch below. Phase B is built: openLog() and
StreamCatalog in the core, the Qt loader removed;
phase B as built records its differences. Phase C is
built: PreferenceStore reads and writes the preference file in the core
(phase C as built). Phases C2, D+E and F are built (phase D+E as built, phase F as built): the core is complete.
sensorviz should be split into a Qt-free core and thin front ends.
- The core holds every decision about what the plot shows and what the data means:
- loading and the stream catalog;
- view state;
- transforms and their parameters;
- preferences;
- plot policy;
- hover and time formatting.
- The Qt app keeps only widgets, menus, dialogs and the QCustomPlot renderer.
- A webview app (Tauri, Saucer or similar) would be a second client of the same core, not a rewrite.
Each concern has one owning C++ class with a narrow interface. CMake enforces the line by never linking Qt to the core.
The decimating backend already did the hardest part.
Sample data, views, hover values, ranges and gaps live in the Qt-free
sensordata library, and QCustomPlot is confined to PlotView. What remains
is the logic still in MainWindow and in Qt-typed structs.
Where the Qt dependency is today
At 6e0134e4, 6,182 lines in host/applications/sensorviz:
| Concern | Where | Qt dependency |
|---|---|---|
| Samples, views, gaps | sensordata |
none |
| Plot policy | buildPlotSpec() in plot_spec.cpp |
QString, QColor, QVector, QMap in its types; the logic is pure |
| Rendering | PlotView |
QCustomPlot; that is its job |
| View state | MainWindow members and QAction check states |
custom_axis_ranges_ is a QMap<QString, QCPRange>, a QCustomPlot type outside the renderer |
| Loading, stream catalog, info, events, calibration | sqlite_loader.cpp, applyLoadedLog() |
QString, QMap, QJsonDocument |
| Transforms (magnitudes, altitude, activity filter, compass) | MainWindow methods in transforms.cpp, compass_transforms.cpp |
QAction, QInputDialog, QQuaternion, sensoranalysis's Qt adapter |
| Heading, pitch, roll | compassAnglesFromQuaternion() (sensorui) |
Qt's QQuaternion::toEulerAngles(), by decision 0030 |
| Preferences | sensor_preferences.cpp |
QJsonDocument, QFile, file dialogs |
| Hover readout, time labels | streamValueAt(), formatPlotTime() in interaction.cpp |
QString, QDateTime, QTimeZone |
About 1,900 lines of domain logic sit in Qt-dependent files. That is
transforms.cpp, compass_transforms.cpp, sensor_preferences.cpp, most of
sqlite_loader.cpp, and the formatting in interaction.cpp.
Class boundaries
┌──────────────── front ends ────────────────┐
│ Qt: MainWindow, PlotView, dialogs │
│ webview: commands + uPlot page (possible) │
│ session_cli: JSON dump (check program) │
└──────────────────────┬─────────────────────┘
│ Session API only
┌──────────────────────── sensorviz_core (Qt-free) ─────────────────────────┐
│ Session ── owns ──┬─ sensordata::Log samples, view(), valueAt() │
│ (one per log) ├─ StreamCatalog what the log and transforms │
│ │ contain: streams, record sets, │
│ │ info, events, calibration │
│ ├─ TransformRegistry ──── Transform (one per transform) │
│ └─ ViewModel what the user chose; plotSpec()│
│ uses ───── PreferenceStore per-tag sparse overrides, JSON;│
│ (app lifetime) outlives any one Session │
│ free functions: buildPlotSpec(), defaultDisplayForStream(), formatTime() │
└────────────────────────────────────────────────────────────────────────────┘
sensordata (Qt-free) sensoranalysis_core (Qt-free)
Rules:
- The core includes no Qt header and links no Qt library.
sensorviz_coreand its check programs build withBUILD_QT_APPS=OFF, so the Linux and Windows build checks catch a stray Qt include. - Core types use
std::string(UTF-8),std::vector,std::map,std::optionaland a smallRgbstruct. Conversion to Qt types happens only in the Qt layer. - Front ends call only
Session. They may readsensordata::Envelopearrays thatSessionhands them, but they never mutate the log, catalog or state directly.
sensordata::Log (exists)
sensordata::Log holds the samples and answers view(), valueAt() and
range(). It also finds gaps and accepts addDerived()/addComputed().
No change is needed. See the
sensordata README.
StreamCatalog
StreamCatalog describes the log for everything above sensordata:
- the stream and record-set metadata from the
streamstable; - the tag type and profile;
- the
infotable; - event markers;
- the newest calibration;
- the derived streams that transforms have registered.
What it replaces. It replaces the Qt-typed SensorLog, SensorStream and
SensorRecordSet, and the metadata half of sqlite_loader.cpp.
How it is filled. A free function, openLog(path, progress), returns
{std::unique_ptr<sensordata::Log>, StreamCatalog}. Calibration JSON goes
through sensoranalysis_core_json, which dataprocessing already uses, so the
numbers are read exactly as today.
Reading the info table and events could move into sensordata instead. Keep
them in the catalog unless another tool needs them.
ViewModel (first slice, in progress)
ViewModel holds what the user chose:
- visible streams and their order;
- custom and explicit ranges;
- colors and axis sides;
- title and its visibility;
- UTC offset;
- cursors and break-at-gaps;
- the current transform parameter values.
plotSpec() calls buildPlotSpec() with the catalog and these choices. Every
setter reports what changed. Menus and dialogs read and write the
ViewModel; they do not store state themselves.
Transform and TransformRegistry
The four transforms become classes, written once and declared to the registry:
struct ParameterSpec {
std::string id, label, units;
enum class Kind { Number, Toggle } kind;
double minimum, maximum, step, defaultValue; // Number only
};
class Transform {
public:
virtual ~Transform() = default;
virtual std::string id() const = 0;
virtual bool applicable(const StreamCatalog &catalog) const = 0; // inputs present?
virtual std::vector<ParameterSpec> parameters() const = 0;
virtual std::vector<std::string> outputs() const = 0; // stream ids
virtual bool run(TransformContext &context, const ParameterValues &values,
std::string &error) = 0;
};
TransformContext gives a transform three things:
- read access to the catalog;
- the
sensordata::Loginput paths:forEachRow()doubles for anything that goes through sensoranalysis, which keeps the bit-identical rule, and the in-memoryfloatcolumns for display transforms such as the IMU magnitudes; - registration of outputs:
addDerived()oraddComputed(), plus a catalog entry with display defaults.
The registry has four jobs:
- run every applicable transform after load;
- re-run only the affected transform when a parameter changes;
- remove its outputs when it stops being applicable;
- list the parameters so a front end can build its own controls.
Parameters are data, not dialog factories. The TODO's "configuration UI
factory" becomes parameters(). The Qt app renders a ParameterSpec with one
generic dialog, replacing the four action-specific dialogs in
transforms.cpp and compass_transforms.cpp; a webview renders it as a form.
Rules that carry over:
- IMUTag orientation is never computed by pairing
ImuAccelwithImuMag. The compass transform is applicable to a CompassTagcompass_rawrecord set, or to a storedcompass_orientationorimu_compass_orientationrecord set fromdataprocessing, and to nothing else. - Settings-dependent results are never written to the log. Transforms register in-memory derived streams only.
PreferenceStore
PreferenceStore holds per-tag sparse overrides: visibility, colors, axis
sides and break-at-gaps. It loads and saves them in today's JSON format, byte
for byte, and the ViewModel applies them.
JSON goes through protobuf's JsonStringToMessage/MessageToJsonString on
google.protobuf.Struct. The host already links protobuf, and
sensoranalysis_core_json uses the same route, so no dependency is added.
Choosing a file stays in the front end.
Session
Session is the facade the front ends see. There is one per open log. It
owns the log, catalog, registry and view model, and uses a PreferenceStore
that the front end owns for the life of the app. That matches today, where
preferences for several tag types survive from one log to the next. It
offers:
| Call | Purpose |
|---|---|
Session::open(path, preferences, progress) |
Build a new session (load, run transforms, apply this tag type's preferences) |
catalog(), viewModel() (const) |
What exists, what is chosen: for building menus |
set…() (forwarded to ViewModel) and setParameter(transform, id, value) |
Every user change; each returns a Changes set |
plotSpec() |
The current spec |
envelope(trace, t0, t1, columns) |
sensordata::Envelope for one trace |
hover(t) |
Rows of {label, text}: today's streamValueAt(), formatted in the core |
formatTime(t) |
Axis and readout time text: epoch with UTC offset, or elapsed |
loadPreferences(bytes), savePreferences() |
Bytes in and out; file choice stays in the front end |
Change reporting. Changes is a small bit set: Spec, Catalog,
Parameters, Preferences. The Qt app maps it to its refreshes, and a webview
command returns it to the page. No observer or signal mechanism is needed.
Threading.
Session::open()builds a new session, so it can run on a worker thread that hands the result to the UI thread.- After that, all mutating calls happen on one thread.
envelope()andhover()are const and may be called concurrently, inheritingsensordata's guarantee.
Front ends
- Qt (the shipped app).
MainWindowbuilds menus fromcatalog()andviewModel()and keeps them in step with one sync function. Every action and dialog calls aSessionsetter and applies the returnedChanges.PlotViewdraws aPlotSpecfromenvelope()arrays. The compass panel, printing and documentation capture stay Qt-specific. session_cli(a check program, not shipped).- It opens a log, applies a script of setter calls, and prints the
PlotSpecas JSON plus envelope sizes and hover rows. - As a third client that is neither Qt nor a webview, it shows the boundary is real.
- It gives the screenshot suites a Qt-free counterpart for regression checks.
- Webview (possible). Each
Sessioncall becomes one shell command. plotSpec()andhover()travel as JSON.envelope()travels as raw binary, aFloat64Arrayof times and aFloat32Arrayof values, about 0.4 MB for a six-stream full redraw.- uPlot draws from
PlotSpectraces and axes. - The shell itself (Tauri over a small C++ facade, or Saucer or
webview/webview directly over
Session) is a separate decision. This split does not depend on it.
Interface sketch
This sets out the shape of the interfaces, not compilable code. Names follow
the existing sensordata API (StreamId, Envelope, Sample, GapMode).
Everything is in namespace sensorviz unless qualified. Strings are UTF-8.
Common types
struct Rgb { uint8_t r, g, b; };
enum class AxisSide { Left, Right };
enum class TimeDomain { Epoch, Elapsed }; // mirrors sensordata::TimeDomain
struct Range { double lower, upper; };
enum class Change : uint32_t { // what a mutation invalidated
None = 0, Spec = 1, Catalog = 2, Parameters = 4, Preferences = 8,
};
using Changes = Flags<Change>; // bit set; Catalog implies Spec
using ParameterValue = std::variant<double, bool>;
using ParameterValues = std::map<std::string, ParameterValue>; // key "transform/param",
// e.g. "altitude/sea_level_mbar"
StreamCatalog
struct StreamEntry {
std::string id; // "imu_ax", "altitude"; unique in the catalog
std::string label, units;
std::string group; // family for linked y ranges ("imu_accel"), or ""
TimeDomain domain;
sensordata::StreamId data; // its samples in the Log; kNoStream if none
enum class Origin { Stored, Derived } origin;
std::string producer; // transform id when Derived, else ""
};
struct RecordSetEntry { // multi-column sets: compass_raw, *_orientation
std::string id, label, table;
TimeDomain domain;
std::vector<std::string> columns;
sensordata::StreamId timeSource;
};
struct EventMarkers { std::string id, label; TimeDomain domain; std::vector<double> times; };
struct CalibrationInfo {
bool present = false;
int64_t epoch = 0;
compasscore::CalibrationConstants constants;
std::string warning; // non-empty when present but suspect
};
class StreamCatalog {
public:
const std::string &path() const;
const std::string &tagType() const;
const std::string &preferenceKey() const; // today's preferenceKey(): tag type/profile
TimeDomain timeDomain() const;
std::optional<int64_t> collectionStartEpochMs() const;
const std::vector<std::pair<std::string, std::string>> &info() const; // File Info rows
const std::vector<StreamEntry> &streams() const; // stored in load order, then derived
const StreamEntry *stream(std::string_view id) const;
const RecordSetEntry *recordSet(std::string_view id) const;
const std::vector<EventMarkers> &events() const;
const CalibrationInfo &calibration() const;
private:
friend class TransformContext; // the only writer after open: adds and removes Derived entries
};
struct OpenedLog { std::unique_ptr<sensordata::Log> log; StreamCatalog catalog; };
std::optional<OpenedLog> openLog(const std::string &path,
const std::function<void(double fraction)> &progress,
std::string &error);
Invariants:
- Every
StreamEntrywithdata != kNoStreamrefers to a live stream in the sameLog. - Stored entries never change after
openLog(). - Derived entries are owned by exactly one transform, their
producer.
Display defaults and PlotSpec
struct DisplayDefaults { Rgb color; bool visible; AxisSide side; std::optional<Range> fixedRange; };
DisplayDefaults defaultDisplayForStream(std::string_view streamId); // today's table, unchanged
enum class AxisRangeMode { Auto, Linked, Fixed };
struct PlotAxis {
AxisSide side; std::string label; Rgb color;
AxisRangeMode mode; std::string linkedGroup;
std::optional<Range> range; // absent: renderer pads its current range
};
struct PlotTrace {
std::string id, name;
sensordata::StreamId data;
Rgb color; int penWidth; int axis; // index into PlotSpec::axes
};
struct PlotSpec {
TimeDomain domain; int utcOffsetHours;
std::optional<Range> xDataRange; // full-range zoom; absent when nothing has samples
std::vector<PlotTrace> traces; // drawing order
std::vector<PlotAxis> axes; // one per trace
std::vector<double> eventMarkers;
std::vector<std::string> metadataRows;
std::string title; // empty: no title
bool breakAtGaps;
};
PlotSpec buildPlotSpec(const StreamCatalog &, const sensordata::Log &,
const ViewState &, const ParameterValues &); // pure
std::string toJson(const PlotSpec &); // session_cli, webview
ViewModel
struct ViewState { // plain, copyable, comparable: what tests diff
std::vector<std::string> visible; // drawing and axis order
std::map<std::string, Range> customRanges;
std::set<std::string> explicitRanges; // ranges the user set, not linked ones
std::map<std::string, Rgb> colors; // overrides only
std::map<std::string, AxisSide> axisSides; // overrides only
std::string title; bool titleVisible = true;
int utcOffsetHours = 0;
bool cursorsVisible = true;
bool breakAtGaps = true;
ParameterValues parameters; // current transform parameter values
};
class ViewModel {
public:
explicit ViewModel(const StreamCatalog &catalog); // catalog outlives the model
const ViewState &state() const;
// Effective values: override, else display default.
bool isVisible(std::string_view id) const;
Rgb color(std::string_view id) const;
AxisSide axisSide(std::string_view id) const;
std::optional<Range> range(std::string_view id) const;
Changes setVisible(std::string_view id, bool visible);
Changes setVisibleSet(std::vector<std::string> idsInOrder);
Changes setColor(std::string_view id, std::optional<Rgb>); // nullopt: back to default
Changes setAxisSide(std::string_view id, std::optional<AxisSide>);
Changes setRange(std::string_view id, std::optional<Range>);
Changes setTitle(std::string); Changes setTitleVisible(bool);
Changes setUtcOffset(int hours); Changes setCursorsVisible(bool); Changes setBreakAtGaps(bool);
Changes setParameter(std::string_view key, ParameterValue); // returns Parameters | Spec
Changes applyPreferences(const TagPreferences &);
Changes catalogChanged(); // drop ids no longer in the catalog; new derived ids take
// their default visibility; keeps order otherwise
PlotSpec plotSpec(const sensordata::Log &) const;
};
Invariants:
visibleholds only ids present in the catalog, with no duplicates.- Overrides equal to the default are not stored, which keeps preferences sparse.
- A setter that changes nothing returns
Change::None.
Transforms
struct ParameterSpec {
std::string key; // "altitude/sea_level_mbar"
std::string label, units;
enum class Kind { Number, Toggle } kind;
double minimum, maximum, step; // Number only
ParameterValue defaultValue;
};
class TransformContext { // what a running transform may touch
public:
const StreamCatalog &catalog() const;
const sensordata::Log &log() const;
// Inputs. Anything passed to sensoranalysis reads doubles from SQLite;
// display-only transforms may read the Log's float columns.
bool forEachRow(const std::string &table, const std::vector<std::string> &columns,
const std::function<void(double time, const double *values)> &,
std::string &error) const;
// Outputs, tagged with the running transform's id.
std::optional<sensordata::StreamId> addDerived(StreamEntry meta, sensordata::StreamId timeFrom,
std::vector<double> values);
std::optional<sensordata::StreamId> addDerived(StreamEntry meta, std::vector<double> time,
std::vector<double> values);
std::optional<sensordata::StreamId> addComputed(StreamEntry meta,
std::vector<sensordata::StreamId> inputs,
ComputeFn);
};
class Transform {
public:
virtual ~Transform() = default;
virtual std::string id() const = 0; // "altitude"
virtual bool applicable(const StreamCatalog &) const = 0; // inputs present
virtual std::vector<ParameterSpec> parameters() const = 0;
virtual std::vector<std::string> outputs(const StreamCatalog &) const = 0;
virtual bool run(TransformContext &, const ParameterValues &, std::string &error) = 0;
};
class TransformRegistry {
public:
static TransformRegistry makeDefault(); // imu-magnitudes, altitude, activity-filter, compass
std::vector<ParameterSpec> parameters(const StreamCatalog &) const; // applicable ones only
bool runAll(TransformContext &, const ParameterValues &, std::vector<std::string> &errors);
// Removes the transform's previous outputs (Log and catalog), then runs it again.
bool rerun(std::string_view transformId, TransformContext &, const ParameterValues &,
std::string &error);
std::string ownerOf(std::string_view parameterKey) const;
};
Invariants:
- A transform reads only stored streams, never another transform's output.
That is true today, so the registry needs no dependency ordering.
TransformContextenforces it by refusing derived inputs. - Compass applicability is
compass_raw, or a storedcompass_orientationorimu_compass_orientationrecord set, and nothing else. - A failed transform leaves no outputs behind and reports its error. Other transforms still run.
PreferenceStore
struct TagPreferences { // sparse overrides for one preference key
std::optional<std::set<std::string>> visible;
std::map<std::string, Rgb> colors;
std::map<std::string, AxisSide> axisSides;
std::optional<bool> breakAtGaps;
};
class PreferenceStore { // app lifetime; outlives Sessions
public:
bool load(std::string_view json, std::string &error); // replaces everything
std::string save() const; // today's formatted JSON, byte-compatible
const TagPreferences *forKey(std::string_view preferenceKey) const;
void capture(std::string_view preferenceKey, const ViewModel &); // differences from defaults only
void clear(std::string_view preferenceKey);
};
Session
struct HoverRow { std::string traceId, label, text; }; // text empty inside a gap
enum class TimeStyle { AxisTick, Readout };
class Session {
public:
static std::unique_ptr<Session> open(const std::string &path, PreferenceStore &preferences,
const std::function<void(double)> &progress,
std::string &error); // worker thread is fine
const StreamCatalog &catalog() const;
const ViewModel &view() const;
std::vector<ParameterSpec> parameters() const;
std::vector<std::string> transformErrors() const; // from the last run
// Mutations: one thread. Each forwards to ViewModel; setParameter also reruns
// the owning transform and calls ViewModel::catalogChanged().
Changes setVisible(std::string_view id, bool);
// ... one forwarder per ViewModel setter ...
Changes setParameter(std::string_view key, ParameterValue);
Changes rememberPreferences(); // capture into the store
Changes resetPreferences(); // clear this key, reapply defaults
Changes reapplyPreferences(); // after the front end loaded a file into the store
// Reads: const, safe concurrently with each other.
PlotSpec plotSpec() const;
sensordata::Envelope envelope(const PlotTrace &, double t0, double t1, int columns) const;
std::vector<HoverRow> hover(double t) const;
std::string formatTime(double t, TimeStyle) const;
};
Qt adapter
class MainWindow : public QMainWindow {
PreferenceStore preferences_; // app lifetime
std::unique_ptr<Session> session_;
PlotView *plot_;
void apply(Changes); // the only refresh path: Catalog rebuilds menus,
// Spec calls plot_->setSpec(session_->plotSpec()),
// always syncActions()
void syncActions(); // checked/visible/enabled from session_->view()
};
class PlotView : public QWidget { // unchanged role: only QCustomPlot code
public:
void setSession(const Session *); // for envelope() and hover()
void setSpec(const PlotSpec &);
signals:
void hoverTime(double t); void xRangeChanged(double, double);
void cursorsChanged(double, double); void contextMenuRequested(QPoint);
};
namespace ParameterDialog { // one dialog for every ParameterSpec
bool edit(QWidget *parent, const ParameterSpec &, ParameterValue &inOut);
}
Webview commands
Each command is a Session call. State and specs travel as JSON, envelopes
as binary.
| Command | Session call | Payload back |
|---|---|---|
open(path) |
Session::open |
catalog JSON, Changes |
set(name, args) |
the matching setter | Changes |
spec() |
plotSpec() |
PlotSpec JSON |
envelope(trace, t0, t1, cols) |
envelope() |
Float64Array time, Float32Array value |
hover(t) |
hover() |
rows JSON |
preferences.load(bytes) / .save() |
store load/save + reapplyPreferences() |
Changes / bytes |
Phase A as built
Phase A (13516ce2) follows this sketch with four differences. Each is kept
or folded into a later phase as follows.
| As built | Sketch | Resolution |
|---|---|---|
ViewModel holds the plot's stream list (StreamMeta; resetForLog(), addOrReplaceStream(), removeStream()) |
StreamCatalog owns streams; ViewModel refers to it |
Phase B moves the list into StreamCatalog; ViewModel keeps only choices about streams |
Fine-grained changes: Streams, Visibility, Ranges, Appearance, Title, UtcOffset, Cursors, Gaps, Parameters |
Spec, Catalog, Parameters, Preferences |
Keep the fine-grained set; Streams is the sketch's Catalog, and every flag implies Spec |
| Setters return their changes and notify an optional listener | Returned changes only | Keep both; the listener suits Qt, the return value suits commands |
Typed parameter setters (setSeaLevelPressure(), setDeclinationDegrees(), setBatteryForward(), setActivityTimeConstant()) |
setParameter(key, value) over ParameterValues |
Phase D replaces them when transforms declare their parameters |
TagPreferences is in the core; its file I/O followed in phase C. The
sensorviz TODO lists everything still Qt-bound.
Phase B as built
sensorviz::openLog() and StreamCatalog (core/stream_catalog.h) replaced
sqlite_loader.cpp; SensorLog, SensorRecordSet and SensorEventMarker
are now aliases of the core's StreamCatalog, RecordSet and EventMarker,
and the Qt app converts strings where it shows them. Calibration is read by
compasscore::calibrationFromJson() (sensoranalysis_core_json) and the
IMUTag magnetometer corrected by compasscore::applyMagnetometer().
| As built | Sketch | Resolution |
|---|---|---|
A plain struct with the old SensorLog field names, holding the std::shared_ptr<sensordata::Log> |
A class with accessors; openLog() returns {unique_ptr<Log>, StreamCatalog} |
Kept for now: the Qt app copies the result between the loader thread and the window, and the field names kept the change to the call sites mechanical. Phase F's Session owns both and can hide the fields |
openLog(path, catalog, error) returning bool |
optional<OpenedLog> with a progress callback |
Progress is not wired in the Qt app today; add it with Session |
ViewModel still holds the stream list, stored and derived |
The catalog owns streams | Moves with phase D: the list changes only when transforms add or remove derived streams, which the registry will own |
No Origin/producer on streams |
StreamEntry::origin, producer |
Phase D, with the registry |
Checked:
- Old and new loaders on every log. A one-off program ran the old Qt
loader and
openLog()side by side over every log on hand: 54 files, five tag types (BitTag LE, CompassTag, IMUTag, PresTag and UIUCTag), 31 of them with calibration tables, and three that both refused. It compared every catalog field, every stream's metadata, every stream's envelope at 1,000 columns and at one column per sample (2,605,928 points, bit for bit), and exact read-back at 201 times per stream. Everything was identical. - Capture suites. The startup,
imutag,compasstagandmenussuites (13 images, including both File Info tabs) were pixel-identical before and after. - Core checks.
plotspec_checkandviewmodel_checkwere unchanged.catalog_checkis new; mutating the accelerometer correction or the resync match makes it fail.
Behaviour differences, none reachable from a log tagcore writes:
- Text that is not JSON now gives the warning "Compass calibration constants are not a valid JSON object: ...", with protobuf's message, instead of Qt's wording.
- A calibration whose
magnetometervalue is an empty object{}loads as the identity calibration; Qt rejected it as missing. - Tag-type, table and event names are matched ignoring ASCII case, not Unicode case.
inforows are ordered by UTF-8 bytes, not UTF-16 code units. The two orders differ only between characters above U+FFFF and those from U+E000 to U+FFFF.
Phase C as built
sensorviz::PreferenceStore (core/preference_store.h) holds the per-tag
TagPreferences and reads and writes the preference file;
sensor_preferences.cpp keeps only the menu, the file dialogs and the file
I/O. Colors are parsed by the core's colorFromString(), which follows
QColor(QString).
| As built | Sketch | Resolution |
|---|---|---|
Reading through protobuf's google.protobuf.Value; writing by hand |
MessageToJsonString for writing too |
protobuf's printer cannot produce Qt's layout (four-space indents, "key": value, one array element per line), so the writer is about sixty lines |
| Keys ordered by UTF-16 code unit | -- | Qt orders object keys so, which puts characters above U+FFFF before U+E000-U+FFFF; the writer sorts the same way, and the reader reports the first fault in that order |
The SVG color table copied from QColor::colorNames() |
-- | Needed to read hand-edited names without Qt; 12- and 16-bit hex channels are rounded through 16 bits as Qt rounds them |
Checked: a one-off program ran the Qt code, copied verbatim, against the
store. Over 3,000 random preference sets (with quotes, backslashes, control
characters, U+2028, U+E000 and characters above U+FFFF in tags and ids) the
saved bytes were identical; over 63,000 loads of those files and random
mutations of them, the result and error message were identical whenever both
readers parsed the JSON; and colorFromString() agreed with QColor on
about 300,000 strings, including every color name in four spellings. The
capture suites were pixel-identical, and a preference file loaded through
--load-preferences drew as expected. preference_check is new.
Malformed JSON is the one difference: protobuf's reader accepts trailing
commas, which Qt refused, and refuses duplicate keys, unknown escapes such as
\, and numbers such as 1., which Qt accepted. None comes from a file
sensorViz writes; matching them would mean writing a JSON parser that copies
Qt's.
Phase D+E as built
core/transforms.h holds ParameterSpec, Transform, TransformContext,
TransformRegistry and the three transforms: altitude, the IMU magnitudes and
the compass, the last on compasscore::solveOrientation() and
compasscore::displayAngles()
(0040).
The Qt app runs the registry after a load, implements the sink, and asks for
numeric parameters with one dialog built from each ParameterSpec.
| As built | Sketch | Resolution |
|---|---|---|
Outputs go to a TransformSink the front end implements, one publish() per output |
The registry adds outputs to the catalog | The Qt app redraws after each output and its y axes take their range partly from the previous drawing, so the order of redraws shows; the sink keeps it. Session (F) implements the sink for non-Qt front ends |
applicable(catalog, view) |
applicable(catalog) |
Altitude and the magnitudes depend on streams, which ViewModel keeps (see the revision above) |
Parameter values live in ViewModel (parameter(id), setParameter(id, value)) over its typed fields |
ParameterValues passed to run() |
The typed setters stay for the plot policy and viewmodel_check |
update(context, parameter) remakes what one parameter affects |
Re-run the affected transform | Declination and battery direction remake only the heading, as before |
The compass keeps its OrientationSamples for the panel |
-- | The QML panel stays Qt; the Rust app will draw its own from the same samples |
No outputs() or Origin/producer |
Declared outputs, owned derived entries | Nothing needed them yet; a transform's outputs are what it publishes |
Checked: sensorViz's --dump-state output was identical before and after
for every log on hand -- 51 that load (18 CompassTag logs on the raw-sample
path, 30 with altitude from pressure and temperature, 13 with the IMU
magnitudes, 24 with the activity filter) -- and for a CompassTag and an IMUTag
log processed by dataprocessing, which take the stored-quaternion path. The
capture suites were pixel-identical. transforms_check (53 checks) is new;
changing the battery-direction offset, the magnitude formula, or the
declination remake to a full re-run each fail it.
Phase F as built
core/session.h: Session owns the StreamCatalog, ViewModel and
TransformRegistry, and holds what MainWindow computed -- the hover text and
time text, the range dialog's starting range, the range links, Reset Zoom,
the preference key -- with open() doing a whole File > Load for front ends
without Qt. core/state_dump.h holds the state dump's script and text, which
the Qt app drives through its own functions and session_cli through
Session alone.
| As built | Sketch | Resolution |
|---|---|---|
MainWindow holds a Session; its log_ and view_ are references to the session's catalog and view model, and most setters still go to view() directly |
Every change through a Session setter returning Changes |
Kept: the ViewModel setters already return Changes, and the session adds rules only where there are any (ranges, parameters, Reset Zoom). Moving every call is churn without effect |
The Qt app loads through adopt()/runTransforms() step by step, redrawing between steps |
Session::open() for everyone |
The Qt plot's y axes depend on the redraw order, so the Qt app keeps its sequence; open() does the same steps for session_cli and the Rust app, and their dumps agree |
session_cli writes the state dump and the hover readout |
PlotSpec as JSON, envelope sizes, hover rows |
The dump holds the spec, every sample's hash and the hover rows, and compares with the Qt app's directly |
formatValue2() prints the exact decimal value and rounds by hand |
printf |
Qt rounds exact ties half away from zero (0.125 is 0.13) and drops the sign of -0.0; printf rounds ties to even |
No envelope() on Session |
envelope(trace, t0, t1, columns) |
catalog().data->view() already is that; the C interface (step 3 of leaving Qt) can wrap it |
Checked:
- A one-off comparison of
formatValue2()andformatTime()with Qt's own formatting (QString::arg(v, 0, 'f', 2), the elapsed format,QDateTimeat every UTC offset from -12 to +12) agreed on 1,000,233 values and times, after 13,076 had differed withprintfrounding. --dump-statefrom the Qt app was identical before and after, for all 53 logs on hand (a baseline from7ce2e6db), once whenMainWindowmoved ontoSessionand again when the dump itself moved into the core. The second run takes the spec fromSessionrather than fromPlotView, so the spec the session builds is the one drawn at every step.session_cli's dump equalled the Qt app's up to the activity filter for all 53 logs, and with a preference file loaded; built without Qt, it gave the same dump.- The capture suites were pixel-identical.
Done means, against the list below:
- No file in
sensorviz_coreincludes a Qt header, and the core and its checks build in a host build withoutBUILD_QT_APPS. MainWindowcomputes on sample values only for the activity filter, which leaves with the Qt app, and in finding the compass panel's nearest sample.session_clireproduces thePlotSpec, every sample and the hover readout the Qt app shows, for every log on hand.
The compass convention needs a decision
Heading, pitch and roll are plotted streams, separate from the compass
visualizer. Decision 0030 defined their convention as Qt's toEulerAngles()
applied where angles are shown, and rejected porting it as unnecessary
"because every display is a Qt display". A Qt-free core removes that
premise.
| Option | Effect |
|---|---|
Copy Qt's QQuaternion::toEulerAngles() into sensormath, gimbal-lock branch included, as vecmath.h already copies QVector3D/QQuaternion arithmetic |
Every displayed angle unchanged; a parity check against Qt joins the existing compass_core_parity_check |
Use the explicit formulas in the dataprocessing design |
Agrees with Qt to 3e-4 degrees, differs near ±90 degrees pitch |
| Adopt a standard Z-Y-X convention | Changes tilted headings everywhere; 0030 already rejected it for that reason |
Recommendation: the first option. A new decision record supersedes only the
"Qt's function at each display" part of 0030; storing only the quaternion
stands. The compass transform then uses sensoranalysis_core, which is
already bit-identical to the Qt adapter, instead of CompassProcessor.
Phases
Each phase leaves the Qt app shipping. Each is checked the way the decimating backend phases were:
- capture suites (
imutag,compasstag, menus) pixel-identical; - hover readouts identical;
- preference files byte-identical;
- full-log memory and load time re-measured into the results log.
| Phase | Work |
|---|---|
| A | sensorviz_core target; Qt-free PlotSpec/buildPlotSpec(); ViewModel; menus sync from it. Built, 13516ce2. |
| B | StreamCatalog and openLog() in the core; calibration through sensoranalysis_core_json; SensorLog/SensorStream retired. Built, cc3ca1c3. |
| C | PreferenceStore with protobuf JSON, byte-compatible. Built, 88830bd3. |
| C2 | sensorviz_core built without Qt, in its own directory (4d0ee4e8); the state dump (--dump-state), the comparison oracle for D-F. Built. |
| D+E | Transform/TransformRegistry; port magnitudes, altitude and compass (onto sensoranalysis_core, with the decision record superseding part of 0030); one generic Qt parameter dialog. Built. |
| F | Session facade, hover()/formatTime() in the core; MainWindow reduced to menus, dialogs and widgets; session_cli, which reproduces the Qt app's --dump-state output without Qt. Built. |
Revised 2026-10-28, after phases B and C:
- C2 comes first.
sensorviz_corewas defined afterfind_package(Qt6 REQUIRED)in sensorviz's CMakeLists, so it could not be built without Qt, and the C interface (step 3 of leaving Qt) needs it in a Qt-free build. The capture suites show only default settings, so before D touches declination, battery forward and sea-level pressure, a dump of every stream, hover row and plot spec under scripted setting changes is the before-and-after check. - D and E are one phase. The Euler conversion E was to copy
(
compasscore::displayAngles(), parity-checked against Qt) was built for Tag Calibrator, so the compass transform goes onto the core directly instead of being ported twice. - The activity filter is not ported. It stays in the Qt sensorViz as it is, outside the registry, and goes when that app retires; the Rust app will not have it. Its prompt-on-enable dialog is the one parameter UI that does not fit a generic form, and it is little used.
ViewModelkeeps the stream list. Transforms add and remove derived streams through it; the catalog stays whatopenLog()read. Moving the list, as the sketch has it, changes nothing visible.
C2 needs C. D+E needs C2. F needs all of them.
Done means:
- No file in
sensorviz_coreincludes a Qt header. MainWindowcontains no computation on sample values, except the activity filter, which leaves with the Qt app.session_clireproduces thePlotSpecthe Qt app draws for every fixture.
Risks
- Bit compatibility at three seams: Euler angles (phase E, parity check),
calibration JSON numbers (phase B,
sensoranalysis_core_jsonalready matches Qt) and preference bytes (phase C, round-trip check). - Paths on Windows.
QStringpaths are UTF-16 today. The core takes UTF-8std::stringand opens SQLite with UTF-8 names, which SQLite expects. The Qt layer converts withQString::toUtf8(). Test a non-ASCII path on Windows. - The size of the change. Phases B–F touch about 1,900 lines of domain
logic and most of
MainWindow. Small phases, each pixel-checked, keep any regression attributable.
Open questions
- Should
infoand event reading move intosensordataso other tools get them, or stay inStreamCatalog? - Should hover and time formatting be localisable? Today's formats are fixed and English. The core would keep them fixed unless a front end needs otherwise.
- Should the compass visualizer move behind a small "orientation at time t" core call, so a webview could draw its own? It is out of scope here.