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.
Files changed (53) hide show
  1. opensmell-3.0.0/LICENSE +21 -0
  2. opensmell-3.0.0/MANIFEST.in +1 -0
  3. opensmell-3.0.0/PKG-INFO +232 -0
  4. opensmell-3.0.0/README.md +205 -0
  5. opensmell-3.0.0/opensmell/__init__.py +239 -0
  6. opensmell-3.0.0/opensmell/calibration.py +313 -0
  7. opensmell-3.0.0/opensmell/constants/__init__.py +137 -0
  8. opensmell-3.0.0/opensmell/constants/sensors.json +386 -0
  9. opensmell-3.0.0/opensmell/csv.py +348 -0
  10. opensmell-3.0.0/opensmell/electrochemical/__init__.py +21 -0
  11. opensmell-3.0.0/opensmell/features.py +47 -0
  12. opensmell-3.0.0/opensmell/hardware.py +148 -0
  13. opensmell-3.0.0/opensmell/ingest.py +195 -0
  14. opensmell-3.0.0/opensmell/io.py +112 -0
  15. opensmell-3.0.0/opensmell/miris/__init__.py +22 -0
  16. opensmell-3.0.0/opensmell/mox/__init__.py +8 -0
  17. opensmell-3.0.0/opensmell/mox/features.py +681 -0
  18. opensmell-3.0.0/opensmell/mox/normalize.py +86 -0
  19. opensmell-3.0.0/opensmell/mox/preprocessing.py +81 -0
  20. opensmell-3.0.0/opensmell/mox/quality.py +259 -0
  21. opensmell-3.0.0/opensmell/mox/smellability/__init__.py +214 -0
  22. opensmell-3.0.0/opensmell/mox/smellability/chain.py +647 -0
  23. opensmell-3.0.0/opensmell/mox/smellability/composites.py +392 -0
  24. opensmell-3.0.0/opensmell/mox/smellability/compounds.py +849 -0
  25. opensmell-3.0.0/opensmell/mox/smellability/constants.py +113 -0
  26. opensmell-3.0.0/opensmell/mox/smellability/enrichment.py +305 -0
  27. opensmell-3.0.0/opensmell/mox/smellability/groups.py +358 -0
  28. opensmell-3.0.0/opensmell/mox/smellability/inference.py +603 -0
  29. opensmell-3.0.0/opensmell/mox/smellability/ontology.py +265 -0
  30. opensmell-3.0.0/opensmell/mox/smellability/provisional.py +87 -0
  31. opensmell-3.0.0/opensmell/mox/smellability/search.py +144 -0
  32. opensmell-3.0.0/opensmell/mox/smellability/transport.py +78 -0
  33. opensmell-3.0.0/opensmell/mox/smellability/types.py +427 -0
  34. opensmell-3.0.0/opensmell/mox/smellability/user_dictionary.py +74 -0
  35. opensmell-3.0.0/opensmell/normalize.py +36 -0
  36. opensmell-3.0.0/opensmell/quality.py +48 -0
  37. opensmell-3.0.0/opensmell/result.py +20 -0
  38. opensmell-3.0.0/opensmell/types.py +330 -0
  39. opensmell-3.0.0/opensmell.egg-info/PKG-INFO +232 -0
  40. opensmell-3.0.0/opensmell.egg-info/SOURCES.txt +51 -0
  41. opensmell-3.0.0/opensmell.egg-info/dependency_links.txt +1 -0
  42. opensmell-3.0.0/opensmell.egg-info/requires.txt +4 -0
  43. opensmell-3.0.0/opensmell.egg-info/top_level.txt +1 -0
  44. opensmell-3.0.0/pyproject.toml +42 -0
  45. opensmell-3.0.0/setup.cfg +4 -0
  46. opensmell-3.0.0/tests/test_calibration.py +305 -0
  47. opensmell-3.0.0/tests/test_hardware_gate.py +134 -0
  48. opensmell-3.0.0/tests/test_ingest.py +118 -0
  49. opensmell-3.0.0/tests/test_legacy_api.py +94 -0
  50. opensmell-3.0.0/tests/test_osmell_io.py +178 -0
  51. opensmell-3.0.0/tests/test_quality.py +69 -0
  52. opensmell-3.0.0/tests/test_r0_contract.py +208 -0
  53. opensmell-3.0.0/tests/test_smellability.py +648 -0
@@ -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
+
@@ -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
+ ]