opensmell 3.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opensmell-3.0.0/LICENSE +21 -0
- opensmell-3.0.0/MANIFEST.in +1 -0
- opensmell-3.0.0/PKG-INFO +232 -0
- opensmell-3.0.0/README.md +205 -0
- opensmell-3.0.0/opensmell/__init__.py +239 -0
- opensmell-3.0.0/opensmell/calibration.py +313 -0
- opensmell-3.0.0/opensmell/constants/__init__.py +137 -0
- opensmell-3.0.0/opensmell/constants/sensors.json +386 -0
- opensmell-3.0.0/opensmell/csv.py +348 -0
- opensmell-3.0.0/opensmell/electrochemical/__init__.py +21 -0
- opensmell-3.0.0/opensmell/features.py +47 -0
- opensmell-3.0.0/opensmell/hardware.py +148 -0
- opensmell-3.0.0/opensmell/ingest.py +195 -0
- opensmell-3.0.0/opensmell/io.py +112 -0
- opensmell-3.0.0/opensmell/miris/__init__.py +22 -0
- opensmell-3.0.0/opensmell/mox/__init__.py +8 -0
- opensmell-3.0.0/opensmell/mox/features.py +681 -0
- opensmell-3.0.0/opensmell/mox/normalize.py +86 -0
- opensmell-3.0.0/opensmell/mox/preprocessing.py +81 -0
- opensmell-3.0.0/opensmell/mox/quality.py +259 -0
- opensmell-3.0.0/opensmell/mox/smellability/__init__.py +214 -0
- opensmell-3.0.0/opensmell/mox/smellability/chain.py +647 -0
- opensmell-3.0.0/opensmell/mox/smellability/composites.py +392 -0
- opensmell-3.0.0/opensmell/mox/smellability/compounds.py +849 -0
- opensmell-3.0.0/opensmell/mox/smellability/constants.py +113 -0
- opensmell-3.0.0/opensmell/mox/smellability/enrichment.py +305 -0
- opensmell-3.0.0/opensmell/mox/smellability/groups.py +358 -0
- opensmell-3.0.0/opensmell/mox/smellability/inference.py +603 -0
- opensmell-3.0.0/opensmell/mox/smellability/ontology.py +265 -0
- opensmell-3.0.0/opensmell/mox/smellability/provisional.py +87 -0
- opensmell-3.0.0/opensmell/mox/smellability/search.py +144 -0
- opensmell-3.0.0/opensmell/mox/smellability/transport.py +78 -0
- opensmell-3.0.0/opensmell/mox/smellability/types.py +427 -0
- opensmell-3.0.0/opensmell/mox/smellability/user_dictionary.py +74 -0
- opensmell-3.0.0/opensmell/normalize.py +36 -0
- opensmell-3.0.0/opensmell/quality.py +48 -0
- opensmell-3.0.0/opensmell/result.py +20 -0
- opensmell-3.0.0/opensmell/types.py +330 -0
- opensmell-3.0.0/opensmell.egg-info/PKG-INFO +232 -0
- opensmell-3.0.0/opensmell.egg-info/SOURCES.txt +51 -0
- opensmell-3.0.0/opensmell.egg-info/dependency_links.txt +1 -0
- opensmell-3.0.0/opensmell.egg-info/requires.txt +4 -0
- opensmell-3.0.0/opensmell.egg-info/top_level.txt +1 -0
- opensmell-3.0.0/pyproject.toml +42 -0
- opensmell-3.0.0/setup.cfg +4 -0
- opensmell-3.0.0/tests/test_calibration.py +305 -0
- opensmell-3.0.0/tests/test_hardware_gate.py +134 -0
- opensmell-3.0.0/tests/test_ingest.py +118 -0
- opensmell-3.0.0/tests/test_legacy_api.py +94 -0
- opensmell-3.0.0/tests/test_osmell_io.py +178 -0
- opensmell-3.0.0/tests/test_quality.py +69 -0
- opensmell-3.0.0/tests/test_r0_contract.py +208 -0
- opensmell-3.0.0/tests/test_smellability.py +648 -0
opensmell-3.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Praise J.J.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
opensmell-3.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: opensmell
|
|
3
|
+
Version: 3.0.0
|
|
4
|
+
Summary: OpenSmell SDK — modular digital-olfaction framework: .osmell I/O, quality scoring, and sensor-specific feature extraction (MOX, MIRIS, electrochemical)
|
|
5
|
+
Author-email: OpenSmell Project <opensmell@users.noreply.github.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/OpenSmell/opensmell
|
|
8
|
+
Project-URL: Repository, https://github.com/OpenSmell/opensmell
|
|
9
|
+
Keywords: digital olfaction,electronic nose,gas sensors,MOX,.osmell
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: pandas
|
|
24
|
+
Requires-Dist: scikit-learn
|
|
25
|
+
Requires-Dist: scipy
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# OpenSmell SDK
|
|
29
|
+
|
|
30
|
+
One Python SDK for digital olfaction: a portable recording container (`.osmell`), ingest,
|
|
31
|
+
quality scoring, an auditable MOX feature framework, reference-point calibration, a
|
|
32
|
+
hardware-sufficiency gate, and a thermodynamic feasibility check ("will my e-nose actually
|
|
33
|
+
smell this?").
|
|
34
|
+
|
|
35
|
+
Everything is reachable from the package root after `import opensmell` — there is no
|
|
36
|
+
separate "legacy" API to learn.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install opensmell
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## The core idea: a recording is `.osmell`
|
|
43
|
+
|
|
44
|
+
A smell recording is a self-describing ZIP: `manifest.json` + `data.csv` (+ optional
|
|
45
|
+
`events.json`). Raw values and the baseline are preserved so any client can pick its own
|
|
46
|
+
normalization.
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import opensmell
|
|
50
|
+
|
|
51
|
+
# Load a recording
|
|
52
|
+
file = opensmell.parse_osmell_file("cinnamon.osmell")
|
|
53
|
+
csv_text = opensmell.csv_from_file(file) # back to CSV if you need it
|
|
54
|
+
opensmell.write_osmell(file, "renamed.osmell") # format version 1.0.0
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Ingest raw CSVs
|
|
58
|
+
|
|
59
|
+
Ingestion never raises on structural weirdness — every interpretation decision is surfaced
|
|
60
|
+
as a warning and errors are captured on the result.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# One file
|
|
64
|
+
s = opensmell.ingest_file("cinnamon.csv", substance="cinnamon", role="exposure")
|
|
65
|
+
# s.ok, s.file (OsmellFile), s.report (QualityReport), s.warnings
|
|
66
|
+
|
|
67
|
+
# A folder of recordings, grouped by subfolder = substance
|
|
68
|
+
col = opensmell.ingest_folder("recordings/")
|
|
69
|
+
for session in col.iter_sessions():
|
|
70
|
+
print(session.substance, session.ok, session.report.badge)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Feature extraction
|
|
74
|
+
|
|
75
|
+
The MOX feature framework is defined **sensor-count-agnostically**. For any channel count
|
|
76
|
+
`c` the vector has `28·c + c(c−1)/2 + 4` features — 28 per channel, one selectivity ratio
|
|
77
|
+
per channel pair, and 4 global metrics. At the canonical six-channel rig that is 187.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import opensmell
|
|
81
|
+
|
|
82
|
+
# Canonical 6-channel rig -> 187 features
|
|
83
|
+
features, names = opensmell.extract_features("cinnamon.csv")
|
|
84
|
+
# features: (N_windows, 187); names: list of 187 names
|
|
85
|
+
avg = features.mean(axis=0)
|
|
86
|
+
|
|
87
|
+
# No hardwired six: names follow the same formula for any channel count
|
|
88
|
+
from opensmell.mox import features as _f
|
|
89
|
+
len(_f.feature_names(n_channels=3)) # 91
|
|
90
|
+
len(_f.feature_names(n_channels=4)) # 122
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Every feature has a name, a category, a transfer class, and a failure mode — the taxonomy
|
|
94
|
+
is mirrored 1:1 with the web stack and the Rust SDK (kept equal by tests).
|
|
95
|
+
|
|
96
|
+
## Classify your own data
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
import opensmell, numpy as np
|
|
100
|
+
|
|
101
|
+
# Build a labelled feature matrix
|
|
102
|
+
X, y = [], []
|
|
103
|
+
for substance in ["cinnamon", "garlic", "coffee"]:
|
|
104
|
+
feats, _ = opensmell.extract_features(f"{substance}.csv")
|
|
105
|
+
X.append(feats.mean(axis=0))
|
|
106
|
+
y.append(substance)
|
|
107
|
+
|
|
108
|
+
model = opensmell.train(np.array(X), y)
|
|
109
|
+
result = opensmell.predict("unknown.csv", model)
|
|
110
|
+
print(f"Predicted: {result.substance} (confidence: {result.confidence:.2f})")
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`process(filepath, model=None)` does feature extraction and (optionally) prediction in one
|
|
114
|
+
call and returns a `SmellResult`. Without a model it never fabricates a substance or
|
|
115
|
+
confidence.
|
|
116
|
+
|
|
117
|
+
## Quality scoring
|
|
118
|
+
|
|
119
|
+
Seven weighted factors (baseline stability, signal strength, continuity, recovery,
|
|
120
|
+
dynamic range, saturation-free, duration) produce a score and a badge —
|
|
121
|
+
Excellent / Good / Fair / Poor / Unknown — with every assumption flagged (used default ADC,
|
|
122
|
+
used median sampling rate, no baseline, non-finite samples, dead sensors).
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
q = opensmell.compute_quality(file, sample_count=len(file.time),
|
|
126
|
+
guess_sampling_rate_hz=10.0)
|
|
127
|
+
print(q.badge, q.total)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Reference-point calibration
|
|
131
|
+
|
|
132
|
+
The SDK fits the MOX power law `R/R0 = a·C^b`, offers a datasheet quick path and a measured
|
|
133
|
+
precise path, and falsifies the fit by leave-one-concentration-out cross-validation.
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
import opensmell
|
|
137
|
+
|
|
138
|
+
quick = opensmell.calibrate_quick("MQ135", "c2h5oh", reference_ppm=100)
|
|
139
|
+
|
|
140
|
+
rr, c = [1.5, 2.1, 3.0], [10, 50, 100]
|
|
141
|
+
precise = opensmell.calibrate_precise("MQ135", "co", rr, c)
|
|
142
|
+
print(precise["calibration"]) # {"a": ..., "b": ...}
|
|
143
|
+
print(precise["loocv"]["mean_abs_pct_error"]) # honest falsification
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A calibration is a power-law point estimate, not an absolute truth: verified cross-device
|
|
147
|
+
affine calibration degrades (47% → 33%), so the SDK never presents calibrated ppm as a
|
|
148
|
+
physical absolute.
|
|
149
|
+
|
|
150
|
+
## Hardware-sufficiency gate
|
|
151
|
+
|
|
152
|
+
A model trained on N channels must not silently run on fewer. The gate checks the rig's
|
|
153
|
+
effective dimensionality against the model's requirement and warns rather than padding a
|
|
154
|
+
dead channel with a mean.
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
import opensmell
|
|
158
|
+
|
|
159
|
+
opensmell.check_rig_sufficiency(n_channels=4, model) # warns if insufficient
|
|
160
|
+
opensmell.implied_channels(187) # 6 — inverts 28c + c(c−1)/2 + 4
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## "Will my e-nose actually smell it?" — the feasibility chain
|
|
164
|
+
|
|
165
|
+
A thermodynamic estimate (not a measurement) answering whether a substance is even a
|
|
166
|
+
feasible target for a MOX array, graded green / yellow / red.
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
from opensmell import smellability
|
|
170
|
+
|
|
171
|
+
verdict = smellability.resolve_and_run("ethanol", "chemical")
|
|
172
|
+
print(verdict.verdict, verdict.confidence, verdict.signal_strength)
|
|
173
|
+
|
|
174
|
+
# Or estimate a brand-new molecule from its SMILES, fully offline
|
|
175
|
+
chem = smellability.chemical_from_smiles("C1=CC=CC(=C1)C=O", name="benzaldehyde")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
The chain computes identity → volatility → signal → reactivity, with exposure/dilution
|
|
179
|
+
guidance and a cross-check against how many substances your sensor count can distinguish.
|
|
180
|
+
It is honest about its limits: a feasibility estimate is not a calibrated concentration, a
|
|
181
|
+
guarantee of mixture decomposition, or a promise across unseen devices.
|
|
182
|
+
|
|
183
|
+
## Feature taxonomy
|
|
184
|
+
|
|
185
|
+
| Group | Per-channel features | Count/channel |
|
|
186
|
+
|-------|----------------------|---------------|
|
|
187
|
+
| Device-agnostic | relative_amplitude, direction, rise_time, decay_time, auc, endpoint_delta | 6 |
|
|
188
|
+
| Absolute | raw_resistance, baseline_resistance, voltage, calibrated_concentration | 4 |
|
|
189
|
+
| Temporal | hf_transient, oscillation_freq, oscillation_amp, response_latency | 4 |
|
|
190
|
+
| Health | drift_rate, sensitivity_decay, noise_floor, hysteresis | 4 |
|
|
191
|
+
| Hardware | circuit_response, thermal_profile, adc_noise | 3 |
|
|
192
|
+
| Advanced | saturation_index + 6 decay terms (tau1–3, a1–3) | 7 |
|
|
193
|
+
|
|
194
|
+
That is 28 per channel, plus `C(c,2)` selectivity ratios and 4 global metrics — `187` at the
|
|
195
|
+
canonical six channels (`91` at three, `406` at twelve). Rs/R₀ normalization cancels Vcc and
|
|
196
|
+
RL in the ratio; it does not cancel the sensor constants (a, b), so cross-device transfer
|
|
197
|
+
requires per-rig reference-point calibration.
|
|
198
|
+
|
|
199
|
+
## CSV convenience functions
|
|
200
|
+
|
|
201
|
+
The thin CSV short-hands wrap the same extractor as the `.osmell` path (they are not a
|
|
202
|
+
separate implementation), for quick one-liners:
|
|
203
|
+
|
|
204
|
+
- `load_recording(path)` → Rs/R₀-normalized array
|
|
205
|
+
- `extract_features(path)` → `(N_windows, 28c+…)` array and names
|
|
206
|
+
- `process(path, model=None)` → `SmellResult` (extract, optionally predict)
|
|
207
|
+
- `train(X, y)` → StandardScaler + RandomForest pipeline (attaches the dimensional floor)
|
|
208
|
+
- `predict(path, model)` → `process` with a model
|
|
209
|
+
- `feature_names(n_channels=None)` → ordered names for any channel count
|
|
210
|
+
|
|
211
|
+
## Data model types
|
|
212
|
+
|
|
213
|
+
`OsmellFile`, `OsmellManifest`, `SensorDescriptor`, `ChannelDescriptor`,
|
|
214
|
+
`CalibrationDescriptor`, `SessionDescriptor`, `SessionEvent`, `ParsedSample`,
|
|
215
|
+
`ChannelStats`, `QualityReport` — serializing to camelCase JSON via `to_dict()` /
|
|
216
|
+
`from_dict()`.
|
|
217
|
+
|
|
218
|
+
## Dependencies
|
|
219
|
+
|
|
220
|
+
- Python 3.10+
|
|
221
|
+
- numpy, pandas, scikit-learn, scipy
|
|
222
|
+
|
|
223
|
+
## Related
|
|
224
|
+
|
|
225
|
+
- [opensmell-rs](https://github.com/opensmell/opensmell-rs) — mirror Rust SDK; same taxonomy
|
|
226
|
+
kept equal by tests (`framework_feature_len(c)`)
|
|
227
|
+
- [interoperability](https://github.com/opensmell/interoperability) — cross-device bounds and
|
|
228
|
+
calibration experiments
|
|
229
|
+
- [Chemoprint](https://github.com/opensmell/chemoprint) — the molecule-half representation
|
|
230
|
+
from SMILES
|
|
231
|
+
|
|
232
|
+
Browse the full reference at [opensmell.org/docs/python](https://opensmell.org/docs/python).
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# OpenSmell SDK
|
|
2
|
+
|
|
3
|
+
One Python SDK for digital olfaction: a portable recording container (`.osmell`), ingest,
|
|
4
|
+
quality scoring, an auditable MOX feature framework, reference-point calibration, a
|
|
5
|
+
hardware-sufficiency gate, and a thermodynamic feasibility check ("will my e-nose actually
|
|
6
|
+
smell this?").
|
|
7
|
+
|
|
8
|
+
Everything is reachable from the package root after `import opensmell` — there is no
|
|
9
|
+
separate "legacy" API to learn.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install opensmell
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## The core idea: a recording is `.osmell`
|
|
16
|
+
|
|
17
|
+
A smell recording is a self-describing ZIP: `manifest.json` + `data.csv` (+ optional
|
|
18
|
+
`events.json`). Raw values and the baseline are preserved so any client can pick its own
|
|
19
|
+
normalization.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import opensmell
|
|
23
|
+
|
|
24
|
+
# Load a recording
|
|
25
|
+
file = opensmell.parse_osmell_file("cinnamon.osmell")
|
|
26
|
+
csv_text = opensmell.csv_from_file(file) # back to CSV if you need it
|
|
27
|
+
opensmell.write_osmell(file, "renamed.osmell") # format version 1.0.0
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Ingest raw CSVs
|
|
31
|
+
|
|
32
|
+
Ingestion never raises on structural weirdness — every interpretation decision is surfaced
|
|
33
|
+
as a warning and errors are captured on the result.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
# One file
|
|
37
|
+
s = opensmell.ingest_file("cinnamon.csv", substance="cinnamon", role="exposure")
|
|
38
|
+
# s.ok, s.file (OsmellFile), s.report (QualityReport), s.warnings
|
|
39
|
+
|
|
40
|
+
# A folder of recordings, grouped by subfolder = substance
|
|
41
|
+
col = opensmell.ingest_folder("recordings/")
|
|
42
|
+
for session in col.iter_sessions():
|
|
43
|
+
print(session.substance, session.ok, session.report.badge)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Feature extraction
|
|
47
|
+
|
|
48
|
+
The MOX feature framework is defined **sensor-count-agnostically**. For any channel count
|
|
49
|
+
`c` the vector has `28·c + c(c−1)/2 + 4` features — 28 per channel, one selectivity ratio
|
|
50
|
+
per channel pair, and 4 global metrics. At the canonical six-channel rig that is 187.
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import opensmell
|
|
54
|
+
|
|
55
|
+
# Canonical 6-channel rig -> 187 features
|
|
56
|
+
features, names = opensmell.extract_features("cinnamon.csv")
|
|
57
|
+
# features: (N_windows, 187); names: list of 187 names
|
|
58
|
+
avg = features.mean(axis=0)
|
|
59
|
+
|
|
60
|
+
# No hardwired six: names follow the same formula for any channel count
|
|
61
|
+
from opensmell.mox import features as _f
|
|
62
|
+
len(_f.feature_names(n_channels=3)) # 91
|
|
63
|
+
len(_f.feature_names(n_channels=4)) # 122
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Every feature has a name, a category, a transfer class, and a failure mode — the taxonomy
|
|
67
|
+
is mirrored 1:1 with the web stack and the Rust SDK (kept equal by tests).
|
|
68
|
+
|
|
69
|
+
## Classify your own data
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import opensmell, numpy as np
|
|
73
|
+
|
|
74
|
+
# Build a labelled feature matrix
|
|
75
|
+
X, y = [], []
|
|
76
|
+
for substance in ["cinnamon", "garlic", "coffee"]:
|
|
77
|
+
feats, _ = opensmell.extract_features(f"{substance}.csv")
|
|
78
|
+
X.append(feats.mean(axis=0))
|
|
79
|
+
y.append(substance)
|
|
80
|
+
|
|
81
|
+
model = opensmell.train(np.array(X), y)
|
|
82
|
+
result = opensmell.predict("unknown.csv", model)
|
|
83
|
+
print(f"Predicted: {result.substance} (confidence: {result.confidence:.2f})")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`process(filepath, model=None)` does feature extraction and (optionally) prediction in one
|
|
87
|
+
call and returns a `SmellResult`. Without a model it never fabricates a substance or
|
|
88
|
+
confidence.
|
|
89
|
+
|
|
90
|
+
## Quality scoring
|
|
91
|
+
|
|
92
|
+
Seven weighted factors (baseline stability, signal strength, continuity, recovery,
|
|
93
|
+
dynamic range, saturation-free, duration) produce a score and a badge —
|
|
94
|
+
Excellent / Good / Fair / Poor / Unknown — with every assumption flagged (used default ADC,
|
|
95
|
+
used median sampling rate, no baseline, non-finite samples, dead sensors).
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
q = opensmell.compute_quality(file, sample_count=len(file.time),
|
|
99
|
+
guess_sampling_rate_hz=10.0)
|
|
100
|
+
print(q.badge, q.total)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Reference-point calibration
|
|
104
|
+
|
|
105
|
+
The SDK fits the MOX power law `R/R0 = a·C^b`, offers a datasheet quick path and a measured
|
|
106
|
+
precise path, and falsifies the fit by leave-one-concentration-out cross-validation.
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import opensmell
|
|
110
|
+
|
|
111
|
+
quick = opensmell.calibrate_quick("MQ135", "c2h5oh", reference_ppm=100)
|
|
112
|
+
|
|
113
|
+
rr, c = [1.5, 2.1, 3.0], [10, 50, 100]
|
|
114
|
+
precise = opensmell.calibrate_precise("MQ135", "co", rr, c)
|
|
115
|
+
print(precise["calibration"]) # {"a": ..., "b": ...}
|
|
116
|
+
print(precise["loocv"]["mean_abs_pct_error"]) # honest falsification
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A calibration is a power-law point estimate, not an absolute truth: verified cross-device
|
|
120
|
+
affine calibration degrades (47% → 33%), so the SDK never presents calibrated ppm as a
|
|
121
|
+
physical absolute.
|
|
122
|
+
|
|
123
|
+
## Hardware-sufficiency gate
|
|
124
|
+
|
|
125
|
+
A model trained on N channels must not silently run on fewer. The gate checks the rig's
|
|
126
|
+
effective dimensionality against the model's requirement and warns rather than padding a
|
|
127
|
+
dead channel with a mean.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import opensmell
|
|
131
|
+
|
|
132
|
+
opensmell.check_rig_sufficiency(n_channels=4, model) # warns if insufficient
|
|
133
|
+
opensmell.implied_channels(187) # 6 — inverts 28c + c(c−1)/2 + 4
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## "Will my e-nose actually smell it?" — the feasibility chain
|
|
137
|
+
|
|
138
|
+
A thermodynamic estimate (not a measurement) answering whether a substance is even a
|
|
139
|
+
feasible target for a MOX array, graded green / yellow / red.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from opensmell import smellability
|
|
143
|
+
|
|
144
|
+
verdict = smellability.resolve_and_run("ethanol", "chemical")
|
|
145
|
+
print(verdict.verdict, verdict.confidence, verdict.signal_strength)
|
|
146
|
+
|
|
147
|
+
# Or estimate a brand-new molecule from its SMILES, fully offline
|
|
148
|
+
chem = smellability.chemical_from_smiles("C1=CC=CC(=C1)C=O", name="benzaldehyde")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The chain computes identity → volatility → signal → reactivity, with exposure/dilution
|
|
152
|
+
guidance and a cross-check against how many substances your sensor count can distinguish.
|
|
153
|
+
It is honest about its limits: a feasibility estimate is not a calibrated concentration, a
|
|
154
|
+
guarantee of mixture decomposition, or a promise across unseen devices.
|
|
155
|
+
|
|
156
|
+
## Feature taxonomy
|
|
157
|
+
|
|
158
|
+
| Group | Per-channel features | Count/channel |
|
|
159
|
+
|-------|----------------------|---------------|
|
|
160
|
+
| Device-agnostic | relative_amplitude, direction, rise_time, decay_time, auc, endpoint_delta | 6 |
|
|
161
|
+
| Absolute | raw_resistance, baseline_resistance, voltage, calibrated_concentration | 4 |
|
|
162
|
+
| Temporal | hf_transient, oscillation_freq, oscillation_amp, response_latency | 4 |
|
|
163
|
+
| Health | drift_rate, sensitivity_decay, noise_floor, hysteresis | 4 |
|
|
164
|
+
| Hardware | circuit_response, thermal_profile, adc_noise | 3 |
|
|
165
|
+
| Advanced | saturation_index + 6 decay terms (tau1–3, a1–3) | 7 |
|
|
166
|
+
|
|
167
|
+
That is 28 per channel, plus `C(c,2)` selectivity ratios and 4 global metrics — `187` at the
|
|
168
|
+
canonical six channels (`91` at three, `406` at twelve). Rs/R₀ normalization cancels Vcc and
|
|
169
|
+
RL in the ratio; it does not cancel the sensor constants (a, b), so cross-device transfer
|
|
170
|
+
requires per-rig reference-point calibration.
|
|
171
|
+
|
|
172
|
+
## CSV convenience functions
|
|
173
|
+
|
|
174
|
+
The thin CSV short-hands wrap the same extractor as the `.osmell` path (they are not a
|
|
175
|
+
separate implementation), for quick one-liners:
|
|
176
|
+
|
|
177
|
+
- `load_recording(path)` → Rs/R₀-normalized array
|
|
178
|
+
- `extract_features(path)` → `(N_windows, 28c+…)` array and names
|
|
179
|
+
- `process(path, model=None)` → `SmellResult` (extract, optionally predict)
|
|
180
|
+
- `train(X, y)` → StandardScaler + RandomForest pipeline (attaches the dimensional floor)
|
|
181
|
+
- `predict(path, model)` → `process` with a model
|
|
182
|
+
- `feature_names(n_channels=None)` → ordered names for any channel count
|
|
183
|
+
|
|
184
|
+
## Data model types
|
|
185
|
+
|
|
186
|
+
`OsmellFile`, `OsmellManifest`, `SensorDescriptor`, `ChannelDescriptor`,
|
|
187
|
+
`CalibrationDescriptor`, `SessionDescriptor`, `SessionEvent`, `ParsedSample`,
|
|
188
|
+
`ChannelStats`, `QualityReport` — serializing to camelCase JSON via `to_dict()` /
|
|
189
|
+
`from_dict()`.
|
|
190
|
+
|
|
191
|
+
## Dependencies
|
|
192
|
+
|
|
193
|
+
- Python 3.10+
|
|
194
|
+
- numpy, pandas, scikit-learn, scipy
|
|
195
|
+
|
|
196
|
+
## Related
|
|
197
|
+
|
|
198
|
+
- [opensmell-rs](https://github.com/opensmell/opensmell-rs) — mirror Rust SDK; same taxonomy
|
|
199
|
+
kept equal by tests (`framework_feature_len(c)`)
|
|
200
|
+
- [interoperability](https://github.com/opensmell/interoperability) — cross-device bounds and
|
|
201
|
+
calibration experiments
|
|
202
|
+
- [Chemoprint](https://github.com/opensmell/chemoprint) — the molecule-half representation
|
|
203
|
+
from SMILES
|
|
204
|
+
|
|
205
|
+
Browse the full reference at [opensmell.org/docs/python](https://opensmell.org/docs/python).
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""opensmell — digital olfaction SDK.
|
|
2
|
+
|
|
3
|
+
v3 modular framework: sensor-agnostic interfaces at the top level
|
|
4
|
+
(`io`, `csv`, `normalize`, `quality`, `features`) with sensor-specific
|
|
5
|
+
implementations in `opensmell.mox` (and future `opensmell.miris`,
|
|
6
|
+
`opensmell.electrochemical`). The MOX thermodynamic feasibility chain lives at
|
|
7
|
+
`opensmell.mox.smellability`.
|
|
8
|
+
|
|
9
|
+
Legacy v2 CSV-based API (`process`, `train`, `predict`, `extract_features`,
|
|
10
|
+
`load_recording`, `SmellResult`) is preserved for backwards compatibility.
|
|
11
|
+
|
|
12
|
+
The feasibility chain is re-exported at the top level as `opensmell.smellability`
|
|
13
|
+
so `resolve_and_run`, `chemical_from_smiles`, and the verdicts are reachable
|
|
14
|
+
without importing the internals.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from sklearn.pipeline import Pipeline
|
|
19
|
+
from sklearn.preprocessing import StandardScaler
|
|
20
|
+
from sklearn.ensemble import RandomForestClassifier
|
|
21
|
+
|
|
22
|
+
from . import features as _features
|
|
23
|
+
from .calibration import (
|
|
24
|
+
CalibrationError,
|
|
25
|
+
build_calibration_payload,
|
|
26
|
+
calibrate_precise,
|
|
27
|
+
calibrate_quick,
|
|
28
|
+
concentration_series,
|
|
29
|
+
fit_power_law,
|
|
30
|
+
invert_concentration,
|
|
31
|
+
loocv_power_law,
|
|
32
|
+
normed_to_rr,
|
|
33
|
+
two_point_calibration,
|
|
34
|
+
)
|
|
35
|
+
from .hardware import (
|
|
36
|
+
HardwareInsufficiencyWarning,
|
|
37
|
+
check_rig_sufficiency,
|
|
38
|
+
effective_dims,
|
|
39
|
+
implied_channels,
|
|
40
|
+
min_effective_dimensions,
|
|
41
|
+
)
|
|
42
|
+
from .mox.preprocessing import load_csv, rs_r0_normalize, segment
|
|
43
|
+
from .result import SmellResult
|
|
44
|
+
|
|
45
|
+
# --- MOX thermodynamic feasibility chain (Smellability) ---
|
|
46
|
+
from .mox import smellability
|
|
47
|
+
|
|
48
|
+
# Register the re-export as a real dotted path so `import opensmell.smellability`
|
|
49
|
+
# (and `from opensmell.smellability import ...`) works, not just attribute access.
|
|
50
|
+
import sys as _sys
|
|
51
|
+
|
|
52
|
+
_sys.modules[__name__ + ".smellability"] = smellability
|
|
53
|
+
del _sys
|
|
54
|
+
|
|
55
|
+
# --- New v3 sensor-agnostic API ---
|
|
56
|
+
from .csv import guess_sensor_type, parse_csv
|
|
57
|
+
from .features import process_mox, run_processor
|
|
58
|
+
from .ingest import (
|
|
59
|
+
IngestedCollection,
|
|
60
|
+
IngestedSession,
|
|
61
|
+
build_osmell_file,
|
|
62
|
+
ingest_file,
|
|
63
|
+
ingest_folder,
|
|
64
|
+
)
|
|
65
|
+
from .io import (
|
|
66
|
+
build_osmell,
|
|
67
|
+
csv_from_file,
|
|
68
|
+
default_file_name,
|
|
69
|
+
parse_osmell,
|
|
70
|
+
parse_osmell_file,
|
|
71
|
+
write_osmell,
|
|
72
|
+
)
|
|
73
|
+
from .quality import compute_quality
|
|
74
|
+
from .types import (
|
|
75
|
+
OSMELL_FORMAT_VERSION,
|
|
76
|
+
CalibrationDescriptor,
|
|
77
|
+
ChannelDescriptor,
|
|
78
|
+
ChannelStats,
|
|
79
|
+
OsmellFile,
|
|
80
|
+
OsmellManifest,
|
|
81
|
+
ParsedSample,
|
|
82
|
+
QualityReport,
|
|
83
|
+
SensorDescriptor,
|
|
84
|
+
SessionDescriptor,
|
|
85
|
+
SessionEvent,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _feature_vector(feature_dict: dict) -> tuple:
|
|
90
|
+
keys = sorted(feature_dict.keys())
|
|
91
|
+
values = [feature_dict[k] for k in keys]
|
|
92
|
+
return np.array(values, dtype=np.float32), keys
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _features_from_normed(normed: np.ndarray, n_channels: int = None) -> tuple:
|
|
96
|
+
if n_channels is None:
|
|
97
|
+
n_channels = normed.shape[1] if normed.ndim == 2 else 1
|
|
98
|
+
segments = segment(normed)
|
|
99
|
+
all_features = []
|
|
100
|
+
for seg in segments:
|
|
101
|
+
feats = _features.extract_all_framework_features(seg)
|
|
102
|
+
vals, _ = _feature_vector(feats)
|
|
103
|
+
all_features.append(vals)
|
|
104
|
+
arr = np.array(all_features)
|
|
105
|
+
fnames = _features.feature_names(n_channels=n_channels)
|
|
106
|
+
return arr, fnames
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_recording(filepath: str) -> np.ndarray:
|
|
110
|
+
raw = load_csv(filepath)
|
|
111
|
+
return rs_r0_normalize(raw)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def extract_features(filepath: str) -> tuple:
|
|
115
|
+
normed = load_recording(filepath)
|
|
116
|
+
return _features_from_normed(normed, n_channels=normed.shape[1])
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def feature_names(n_channels=None) -> list:
|
|
120
|
+
"""Names of the MOX framework features (in extraction order).
|
|
121
|
+
|
|
122
|
+
Length is a function of channel count (``28·c + c(c−1)/2 + 4``); pass
|
|
123
|
+
``n_channels`` to match a non-6 rig, or omit for the canonical 6.
|
|
124
|
+
"""
|
|
125
|
+
return _features.feature_names(n_channels=n_channels)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def process(filepath: str, model: Pipeline = None) -> SmellResult:
|
|
129
|
+
normed = load_recording(filepath)
|
|
130
|
+
if model is not None:
|
|
131
|
+
check_rig_sufficiency(normed.shape[1], model)
|
|
132
|
+
features_arr, fnames = _features_from_normed(normed)
|
|
133
|
+
if features_arr.shape[0] == 0:
|
|
134
|
+
return SmellResult(features=np.array([]), feature_names=fnames, n_windows=0)
|
|
135
|
+
avg_features = features_arr.mean(axis=0)
|
|
136
|
+
if model is not None:
|
|
137
|
+
pred = model.predict([avg_features])[0]
|
|
138
|
+
proba = model.predict_proba([avg_features]).max()
|
|
139
|
+
warning = ""
|
|
140
|
+
if proba < 0.5:
|
|
141
|
+
warning = "Low confidence"
|
|
142
|
+
elif proba < 0.7:
|
|
143
|
+
warning = "Moderate confidence"
|
|
144
|
+
return SmellResult(
|
|
145
|
+
substance=str(pred),
|
|
146
|
+
confidence=float(proba),
|
|
147
|
+
warning=warning,
|
|
148
|
+
features=avg_features,
|
|
149
|
+
feature_names=fnames,
|
|
150
|
+
n_windows=features_arr.shape[0],
|
|
151
|
+
)
|
|
152
|
+
return SmellResult(
|
|
153
|
+
features=avg_features,
|
|
154
|
+
feature_names=fnames,
|
|
155
|
+
n_windows=features_arr.shape[0],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def train(X: np.ndarray, y: np.ndarray, n_estimators: int = 200) -> Pipeline:
|
|
160
|
+
model = Pipeline([
|
|
161
|
+
("scaler", StandardScaler()),
|
|
162
|
+
("clf", RandomForestClassifier(
|
|
163
|
+
n_estimators=n_estimators,
|
|
164
|
+
class_weight="balanced",
|
|
165
|
+
random_state=42,
|
|
166
|
+
n_jobs=-1,
|
|
167
|
+
)),
|
|
168
|
+
])
|
|
169
|
+
model.fit(X, y)
|
|
170
|
+
n_ch = implied_channels(X.shape[1])
|
|
171
|
+
if n_ch is not None:
|
|
172
|
+
model.min_effective_dimensions = effective_dims(n_ch)
|
|
173
|
+
return model
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def predict(filepath: str, model: Pipeline) -> SmellResult:
|
|
177
|
+
return process(filepath, model=model)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Backwards-compat alias for the .osmell loader.
|
|
181
|
+
load_osmell = parse_osmell_file
|
|
182
|
+
|
|
183
|
+
__all__ = [
|
|
184
|
+
"extract_features",
|
|
185
|
+
"feature_names",
|
|
186
|
+
"process",
|
|
187
|
+
"train",
|
|
188
|
+
"predict",
|
|
189
|
+
"load_recording",
|
|
190
|
+
"SmellResult",
|
|
191
|
+
# Reference-point calibration (§4.6, §10.10)
|
|
192
|
+
"CalibrationError",
|
|
193
|
+
"two_point_calibration",
|
|
194
|
+
"fit_power_law",
|
|
195
|
+
"invert_concentration",
|
|
196
|
+
"loocv_power_law",
|
|
197
|
+
"build_calibration_payload",
|
|
198
|
+
"concentration_series",
|
|
199
|
+
"normed_to_rr",
|
|
200
|
+
"calibrate_quick",
|
|
201
|
+
"calibrate_precise",
|
|
202
|
+
# Hardware sufficiency gate (§10.10 N→M limit)
|
|
203
|
+
"HardwareInsufficiencyWarning",
|
|
204
|
+
"check_rig_sufficiency",
|
|
205
|
+
"effective_dims",
|
|
206
|
+
"min_effective_dimensions",
|
|
207
|
+
"implied_channels",
|
|
208
|
+
# v3 sensor-agnostic API
|
|
209
|
+
"parse_csv",
|
|
210
|
+
"guess_sensor_type",
|
|
211
|
+
"ingest_file",
|
|
212
|
+
"ingest_folder",
|
|
213
|
+
"build_osmell_file",
|
|
214
|
+
"IngestedSession",
|
|
215
|
+
"IngestedCollection",
|
|
216
|
+
"parse_osmell",
|
|
217
|
+
"parse_osmell_file",
|
|
218
|
+
"load_osmell",
|
|
219
|
+
"build_osmell",
|
|
220
|
+
"write_osmell",
|
|
221
|
+
"csv_from_file",
|
|
222
|
+
"default_file_name",
|
|
223
|
+
"compute_quality",
|
|
224
|
+
"run_processor",
|
|
225
|
+
"process_mox",
|
|
226
|
+
# MOX thermodynamic feasibility chain (Smellability)
|
|
227
|
+
"smellability",
|
|
228
|
+
"OSMELL_FORMAT_VERSION",
|
|
229
|
+
"OsmellFile",
|
|
230
|
+
"OsmellManifest",
|
|
231
|
+
"SensorDescriptor",
|
|
232
|
+
"SessionDescriptor",
|
|
233
|
+
"ChannelDescriptor",
|
|
234
|
+
"SessionEvent",
|
|
235
|
+
"ParsedSample",
|
|
236
|
+
"ChannelStats",
|
|
237
|
+
"QualityReport",
|
|
238
|
+
"CalibrationDescriptor",
|
|
239
|
+
]
|