Install
The package is opensmell, requires Python ≥3.10, and depends on numpy, pandas, scikit-learn, and scipy. It declares no console-script entry points; the smellability CLI is a standalone script in the repository.
pip install opensmellAfter import, everything is reachable from the package root — there is no need to import internals:
import opensmell # data model & version opensmell.OSMELL_FORMAT_VERSION # "1.0.0" # .osmell I/O file = opensmell.parse_osmell_file("rec.osmell") csv_text = opensmell.csv_from_file(file) opensmell.write_osmell(file, "out.osmell") # feature extraction opensmell.extract_features("rec.csv") opensmell.feature_names() # thermodynamic feasibility chain from opensmell import smellability verdict = smellability.resolve_and_run("ethanol", "chemical")API surface
The package exposes a single coherent API at the root — there are no separate public generations. One flat namespace mirrors one framework: typed descriptors and .osmell containers for I/O, plus the same internal machinery surfaced through both the low-level functions and the convenience wrappers (load_recording, extract_features, train, process, predict are thin wrappers over the same framework, not a separate legacy stack). The groups below are organizational categories, not API tiers.
| Category | API |
|---|---|
| Container I/O | parse_osmell, parse_osmell_file, build_osmell, write_osmell, csv_from_file, default_file_name |
| Ingest | parse_csv, guess_sensor_type, ingest_file, ingest_folder, build_osmell_file |
| Processing & features | run_processor, process_mox, extract_features, feature_names, compute_quality |
| Calibration | two_point_calibration, fit_power_law, loocv_power_law, invert_concentration, normed_to_rr, calibrate_quick, calibrate_precise, build_calibration_payload, concentration_series |
| Hardware gate | check_rig_sufficiency, effective_dims, min_effective_dimensions, implied_channels |
| Smellability | smellability (subpackage) — resolve_and_run, chemical_from_smiles, verdict types |
The .osmell container
.osmell is the portable recording container. It is a ZIP archive with a manifest.json, a data.csv member (the per-channel samples), and an optional events.json. The current format version is 1.0.0. All I/O functions accept either a path or raw bytes.
| Function | Signature | What it does |
|---|---|---|
parse_osmell | parse_osmell(data: bytes) → OsmellFile | Parse an in-memory bundle (ZIP bytes) into an OsmellFile, validating manifest/data consistency. |
parse_osmell_file | parse_osmell_file(path) → OsmellFile | Load a .osmell bundle from disk (delegates to parse_osmell). |
build_osmell | build_osmell(file) → bytes | Serialize an OsmellFile to a ZIP bundle (DEFLATE). |
write_osmell | write_osmell(file, path) → Path | Write an OsmellFile to disk as a .osmell bundle. |
csv_from_file | csv_from_file(file) → str | Serialize the data channel back to CSV text. |
default_file_name | default_file_name(file, role=None) → str | Suggest <label>_<role>_<date>.osmell, sanitized. |
Data model
The typed descriptors serialize to camelCase JSON via uniform to_dict()/from_dict() methods. The key types:
| Type | Fields |
|---|---|
OsmellFile | manifest · time (List[float]) · data (dict[str, List[float]]) · events? |
OsmellManifest | osmell (formatVersion), sensor, session, baseline?, software?, extra |
SensorDescriptor | sensor_type (default "mox"), channels, device?, sampling_rate_hz?, adc_bits?, adc_max?, time_column, calibration? |
ChannelDescriptor | id, unit, target? |
CalibrationDescriptor | a, b, reference_substance?, reference_ppm?, date?, method? |
SessionDescriptor | role (single/baseline/exposure), label?, group_id?, recorded_at?, duration_ms?, notes? |
SessionEvent | label, start_ms, end_ms?, note? |
ParsedSample | time, values: dict[str, float|None] |
ChannelStats | id, min, max, mean, std, r0, cv, dead, span, clipped, non_finite |
QualityReport | format, version, computed_at, total?, badge, subscores, flags, reasons, notes |
Session roles are baseline, exposure, or single. Baseline sources are explicit, auto, or none. Quality sub-scores for signal strength and recovery are None for non-exposure roles by design.
Ingest
Ingestion turns a raw CSV into an OsmellFile with provenance metadata, gracefully. It never raises for structural problems — errors are captured on the returned session object.
| Function | Signature | What it does |
|---|---|---|
parse_csv | parse_csv(text) → CsvParseResult | Parse raw CSV text: detect delimiter, time column, sensor vs context channels; synthesize timing if absent; auto-convert epoch seconds; sort out-of-order rows; report every interpretation in warnings. |
guess_sensor_type | guess_sensor_type(header) → str | Guess sensor family from column names; "mox" if ≥2 columns match the MOX set, else "unknown". |
ingest_file | ingest_file(path, substance=None, role="single") → IngestedSession | Ingest one CSV/TXT; computes the quality report internally; errors captured in session.error. |
ingest_folder | ingest_folder(path, recurse=True, label_from_dir=True) → IngestedCollection | Ingest a folder, grouping by subfolder = substance. |
build_osmell_file | build_osmell_file(parsed, label, substance, source, role="single") → OsmellFile | Normalize a parsed CSV into an OsmellFile with ingest provenance. |
Collected folders return an IngestedCollection keyed by substance, with session_count(), ok_count(), and iter_sessions(). The recognized MOX channel ids are VOC, Alcohol, LPG, CO, NO2, C2H5OH.
Feature extraction
The MOX framework is defined sensor-count-agnostically. For any channel count c, the vector has 28·c + c(c−1)/2 + 4 features — 28 features per channel, plus one selectivity ratio per unordered channel pair, plus 4 global metrics. At the canonical c = 6 rig that is 28×6 + 15 + 4 = 187; implied_channels(187) → 6 inverts the same formula. It is auditable: every feature has a name, and the SDK tests assert the exact (187,) shape for the 6-channel recorder.
The formula is channel-agnostic, but today the Python extractor is wired to a fixed N_CHANNELS = 6 (feature_names() /extract_all_framework_features). The Rust SDK exposes the same count as a framework_feature_len(n_channels) function. So 187 is the 6-channel instance, not a universal constant.
| Family | 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 for each unordered channel pair | 15 total |
| Global | max_delta_ratio, mean_delta_ratio, n_active_channels, total_auc | 4 total |
Top-level entry points
| Function | Signature | What it does |
|---|---|---|
run_processor | run_processor(file: OsmellFile) → dict | Dispatch by sensor_type: MOX → process_mox; miris/electrochemical → raw data (no extractor yet); other → marker dict. |
process_mox | process_mox(file) → dict | Per-channel kinetic features mirroring the web processMox; returns features + normalized series. |
extract_features | extract_features(filepath) → tuple | Full framework vector (array, names) from a CSV. |
feature_names | feature_names() → list | Ordered feature names (187 at the fixed 6-channel extractor). |
miris and electrochemical sensor types have no feature extractor or quality scorer yet — calls raise NotImplementedError. This is documented as an open item, not hidden.
Quality scoring
compute_quality dispatches to the MOX scorer, a seven-factor weighted scoring model, and returns a QualityReport with a badge (Excellent / Good / Fair / Poor / Unknown).
| Factor | Weight | What it measures |
|---|---|---|
| baselineStability | 0.20 | CV of the baseline window. |
| signalStrength | 0.20 | Response relative to the noise floor. |
| continuity | 0.15 | Row/timing regularity. |
| recoveryCompleteness | 0.15 | Return toward baseline (exposure roles). |
| dynamicRange | 0.10 | Fraction of ADC range used. |
| saturationFree | 0.10 | Absence of clipping. |
| durationAdequacy | 0.10 | Duration vs target. |
Flags track dead_sensors, unsorted_rows, non_finite_samples, used_default_adc_max, used_median_sampling_rate, no_baseline, and empty_recording — so the scorer's assumptions are always visible.
Calibration
Calibration fits the power law R/R0 = a·C^b. The quick path reads datasheet constants; the precise path fits measured points and falsifies the fit by leave-one-concentration-out cross-validation.
| Function | What it does |
|---|---|
normed_to_rr | Convert normalized response (R-R0)/R0 to resistance ratio R/R0 (rr = 1 + normed). |
invert_concentration | Invert the power law to concentration C = (rr/a)^(1/b); NaN where undefined. |
two_point_calibration | Exact (a,b) from two (rr, C) points; raises CalibrationError on degenerate input. |
fit_power_law | Multi-point log-log fit; returns a, b, r2, RMSE, min/max ppm, decades, residuals. |
loocv_power_law | Leave-one-concentration-out falsification: mean/median/max % error + bias. |
calibrate_quick | Datasheet-derived single-channel calibration from the embedded constants table. |
calibrate_precise | Measured multi-point calibration wrapper returning payload + diagnostics + LOOCV. |
build_calibration_payload | Build a manifest sensor.calibration payload from per-channel fits. |
concentration_series | Per-channel concentration time series (uncalibrated channels → NaN). |
Calibration is a power-law point estimate, not an absolute truth. Verified cross-device work shows affine calibration degrades (47% → 33%); the engine never claims calibrated ppm as a physical absolute — headspace ppm is a thermodynamic estimate.
Hardware sufficiency gate
The gate stops a model trained on N channels from silently running on fewer. Resolution order for the required dimensional floor: the model's own min_effective_dimensions, then inference from n_features_in_, then a class-count heuristic.
| Function | What it does |
|---|---|
effective_dims | Empirical effective dimensionality of a same-family MOX rig (1→0.5, 2→1.0, 3→1.5, 4→2.0, 5+→2.5). |
implied_channels | Invert 28c + c(c-1)/2 + 4 = n to recover the channel count; None for non-canonical counts. |
min_effective_dimensions | The dimensional floor a model requires. |
check_rig_sufficiency | Warn-and-proceed gate: emits HardwareInsufficiencyWarning when insufficient. |
Smellability — the feasibility chain
The MOX thermodynamic feasibility chain answers “will my e-nose actually smell this?” — a physical feasibility estimate, not a calibrated measurement. It is MOX-specific, not sensor-agnostic, and is re-exported at opensmell.smellability.
The four-step chain
identity— resolve the entity and its measured properties.volatility— vapor pressure at ambient temperature (explicit, Antoine, gas/Clausius-Clapeyron fallbacks).signal— incident flux vs the ethanol reference, graded strong/moderate/weak/none.reactivity— whether the compound is redox-active in the reducing direction MOX sensors can see.
Key functions
| Function | What it does |
|---|---|
resolve_and_run | Top-level entry: resolve an entity by id+kind (chemical/composite/class) and run the chain. |
chemical_from_smiles | Build an estimated Chemical from SMILES fully offline (MW, Joback Tb, functional groups, redox). |
run_chemical_verdict | Run the full chain for a chemical, with cross-check and guidance. |
run_composite_verdict | Weighted aggregation over a composite's constituents. |
run_class_verdict | Coarse class-level estimate for a functional class (alcohol, ester, …). |
search_substances | Fuzzy search across chemicals, composites, user dictionary, and classes. |
The chain returns a FeasibilityVerdict: verdict (green/yellow/red), confidence, signal strength, response speed, per-constituent steps, exposure and dilution guidance, and a cross-check against how many substances the sensor count can actually distinguish.
The verdict is a physical feasibility estimate. It is not a calibrated concentration, a guarantee of mixture decomposition, a promise across unseen devices, or a replacement for capture discipline. Six pure anchors cannot cover odorant space (≈0.1% of 4,565 odorants), and the design ships a contribution loop rather than a “calibrate to these bottles” flow.
Constants & ontology
The chain ships a curated catalogue (~46 chemicals, 24 composites), the reference compound ethanol, 14 percept categories, and 9 capability boundaries (4 can, 5 cannot). MOX detection floor is 1 ppm; default sensor count is 6; the per-sensor-count distinguishable-substance limits are tabulated (3→6, 4→12, 5→20, 6→40, 12→200, 24→10000).
Convenience pipeline API
The low-level machinery is also surfaced through a convenience pipeline for the common CSV flow. load_recording loads and normalizes a CSV; extract_features segments and extracts the framework; train builds a standard-scaler + random-forest Pipeline (attaching the dimensional floor); process / predict run the full pipeline and return a SmellResult with a chemoprint property (a fixed-length 29-element slice). These are thin wrappers over the same framework as the rest of the package — a convenience entry point, not a separate API tier.
Constants table
The opensmell.constants module holds the offline datasheet table of power-law responses, keyed by sensor model. Upstream types like opensmell re-export the readable functions only indirectly — access via opensmell.constants:
| Function | What it does |
|---|---|
sensor_models | Sorted list of known sensor models. |
sensor_gases | Gases a sensor model has response constants for. |
clean_air_ratio | Clean-air Rs/R0 for a model. |
power_law | The (a, b) response for a sensor responding to a gas. |
all_power_laws | All (a, b) responses for a sensor. |
sensor_sources | Datasheet / verification URLs. |
CLI script
A standalone script runs smellability lookups without writing code:
python scripts/smellability_lookup.py "benzaldehyde" python scripts/smellability_lookup.py --smiles "C1=CC=CC(=C1)C=O" python scripts/smellability_lookup.py --mix "banana:0.5,coffee:0.5"--sensor-count/--libraryset the cross-check rig.--jsonprints machine-readable output.- Exit codes: 0 success, 2 unresolved, 3 usage error.
