The .osmell container
.osmell is a portable, self-describing ZIP archive for a smell recording. Current format version 1.0.0. A bundle is three members:
| Member | Required | Contents |
|---|---|---|
manifest.json | Yes | Device, sensor, and session metadata (see below). |
data.csv | Yes | Per-channel time series; header + rows. |
events.json | No | Optional labelled events (e.g. phase transitions). |
The MIME type is application/vnd.opensmell.osmell. The format is designed to preserve raw values and the baseline so any client can choose its own normalization rather than inheriting one choice baked into the file.
Manifest schema
{
"osmell": { "formatVersion": "1.0.0" },
"sensor": {
"sensorType": "mox",
"channels": [{ "id": "VOC", "unit": "ohm" }, "..."],
"adcMax": 4095,
"samplingRateHz": 10,
"timeColumn": "timestamp_ms",
"calibration": { "ch0": { "a": ..., "b": ... } }
},
"session": {
"role": "exposure",
"label": "...",
"groupId": "...",
"recordedAt": "...",
"durationMs": ...
},
"baseline": { "source": "explicit | auto | none", "r0Samples": 15 },
"software": { "recorder": "..." }
}Session roles are baseline, exposure, or single. The manifest is self-describing, so a reader can detect a mismatch (e.g. a data.csv whose columns do not match the declared channels) and fail loudly rather than silently disagree.
The feature framework
The canonical framework is defined sensor-count-agnostically: for any channel count c the vector has 28·c + c(c−1)/2 + 4 features — 28 per channel, one selectivity ratio per unordered channel pair, plus 4 global metrics. At the canonical c = 6 MOX array that is 28×6 + 15 + 4 = 187. The arithmetic and every feature name is auditable, and the SDK and web stack keep the same taxonomy — 1:1 — by test. implied_channels(187) (Python) and framework_feature_len(n_channels) (Rust) both express this formula over channel count directly.
| Group | Per-channel features | Count/channel |
|---|---|---|
| Device-agnostic | relative_amplitude, direction, rise_time, decay_time, auc, endpoint_delta | 6 |
| Absolute | raw_resistance, baseline_resistance, voltage, calibrated_concentration | 4 |
| Temporal | hf_transient, oscillation_freq, oscillation_amp, response_latency | 4 |
| Health | drift_rate, sensitivity_decay, noise_floor, hysteresis | 4 |
| Hardware | circuit_response, thermal_profile, adc_noise | 3 |
| Advanced | saturation_index + 6 decay terms (tau1–3, a1–3) | 7 |
| Cross-channel | selectivity ratio per unordered channel pair | 15 total |
| Global | max_delta_ratio, mean_delta_ratio, n_active_channels, total_auc | 4 total |
The framework is device-agnostic by construction: it recomputes cleanly for any channel count, and the hardware-insufficiency gate ensures a model trained with six channels is never silently run on five by padding a dead channel with a mean.
Every feature has a name, a category, a transfer class, and a failure mode — the vector is high-dimensional, but the model does not treat two same-family MOX channels as independent: effective dimensionality is well below the raw (187) channel count.
Known limitations
- Affine calibration degrades cross-device (47% → 33%); the engine reports calibrated ppm only as a thermodynamic estimate.
- Session invariance comes from learning, not architecture: 81.78% on held-out sessions, for trained substances — not zero-shot generalization.
- Two same-family MOX channels are ≈1 effective dimension; humidity is common-mode across SnO₂.
- A verdict is a physical feasibility estimate, not a promise across unseen devices or a substitute for capture discipline.
Representation stack
Two representations share the stack, deliberately:
- The feature framework (sensor half) — 187 temporal response features extracted from raw traces: what the instrument measured.
- The chemoprint (molecule half) — a fixed-length numeric fingerprint of the substance: what is in the air.
Together they cover both ends, with zero lock-in — the signatures interchange across the Python SDK, the Rust SDK, the web platform, and the desktop app.
