Skip to content

Commit 3cccd56

Browse files
Pull the whole public-domain odor corpus + fix name display (#81)
build_odor_notes.py gains --pubchem-all: pulls the ENTIRE public-domain PubChem odor set via the annotations API (odor text + source + CID inline, all pages), filtered to HSDB/Haz-Map/CAMEO, resolving CIDs to InChIKeys in batches. ~20 requests instead of a 3k-molecule crawl; odor_notes.parquet goes from ~190 to ~2,260 molecules. Name fix: PubChem's Title for a flattened structure is often the systematic name (cinnamaldehyde -> '3-Phenylprop-2-Enal'), which read as the IUPAC name shown twice. Now api/names uses the user's typed name as the common name when they searched by name, and the workbench never renders common when it equals the IUPAC string. Signed-off-by: Austin L. <86896075+rvnminers-A-and-N@users.noreply.github.com>
1 parent c4fe1c7 commit 3cccd56

3 files changed

Lines changed: 102 additions & 5 deletions

File tree

training/app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,14 @@ def api_neighbors(q: Query):
134134
@app.post("/api/names")
135135
def api_names(q: Query):
136136
"""Common (PubChem Title) + IUPAC names for the queried molecule."""
137-
smi = _resolve(q.smiles)
137+
raw = (q.smiles or "").strip()
138+
smi = _resolve(raw)
138139
common, iupac = _names(smi) if smi else (None, None)
140+
# If the user searched by NAME (not a SMILES), that IS the best common name — PubChem's
141+
# Title for a flattened structure is often the systematic name (e.g. "cinnamaldehyde"
142+
# resolves to Title "3-Phenylprop-2-Enal"), which then looks like the IUPAC name repeated.
143+
if smi and raw and Chem.MolFromSmiles(raw) is None:
144+
common = raw[:1].upper() + raw[1:]
139145
return {"common": common, "iupac": iupac, "smiles": smi}
140146

141147

training/build_odor_notes.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import re
2626
import statistics
2727
import sys
28+
import urllib.parse
2829
from pathlib import Path
2930

3031
import pandas as pd
@@ -138,6 +139,78 @@ def fetch_record(inchikey):
138139
"odor_threshold_source": thr_src}
139140

140141

142+
_ANNO = "https://pubchem.ncbi.nlm.nih.gov/rest/pug_view/annotations/heading/JSON"
143+
144+
145+
def annotation_records(heading):
146+
"""Yield (cid, [strings], source) from PubChem's annotations API — inline data across ALL
147+
pages, public-domain sources only. The whole 'Odor' set is ~3 requests, not ~3k crawls."""
148+
page = 1
149+
while True:
150+
d = _get(f"{_ANNO}?heading_type=Compound&heading={urllib.parse.quote(heading)}&page={page}")
151+
ann = (d or {}).get("Annotations", {})
152+
for rec in ann.get("Annotation", []):
153+
src = (rec.get("SourceName") or "").strip()
154+
if not _pd_source(src):
155+
continue
156+
cids = rec.get("LinkedRecords", {}).get("CID", [])
157+
if not cids:
158+
continue
159+
strs = []
160+
for dd in rec.get("Data", []):
161+
for s in dd.get("Value", {}).get("StringWithMarkup", []) or []:
162+
t = " ".join((s.get("String") or "").split()).strip()
163+
if t:
164+
strs.append(t)
165+
if strs:
166+
yield cids[0], strs, src
167+
if page >= ann.get("TotalPages", 1):
168+
break
169+
page += 1
170+
171+
172+
def cids_to_inchikeys(cids, chunk=100):
173+
"""{cid: InChIKey} via batched PubChem property calls (~1 request per 100 CIDs)."""
174+
out = {}
175+
for i in range(0, len(cids), chunk):
176+
ch = cids[i:i + chunk]
177+
d = _get(f"{_BASE}/pug/compound/cid/{','.join(map(str, ch))}/property/InChIKey/JSON")
178+
for p in (d or {}).get("PropertyTable", {}).get("Properties", []):
179+
if p.get("InChIKey"):
180+
out[p["CID"]] = p["InChIKey"]
181+
return out
182+
183+
184+
def build_from_pubchem_annotations():
185+
"""Rows for the WHOLE public-domain PubChem odor set via the annotations API (inline data).
186+
~3 requests for odor + ~1 for threshold + a batched CID->InChIKey resolve — not a 3k crawl."""
187+
odor_by_cid = {}
188+
for cid, strs, src in annotation_records("Odor"):
189+
o, s = odor_by_cid.get(cid, ([], set()))
190+
odor_by_cid[cid] = (o + strs, s | {src})
191+
thr_by_cid = {}
192+
for cid, strs, src in annotation_records("Odor Threshold"):
193+
thr_by_cid.setdefault(cid, []).extend((src, t) for t in strs)
194+
all_cids = sorted(set(odor_by_cid) | set(thr_by_cid))
195+
print(f"annotations: {len(odor_by_cid)} odor + {len(thr_by_cid)} threshold CIDs "
196+
f"(public-domain); resolving {len(all_cids)} InChIKeys...", flush=True)
197+
cid2ik = cids_to_inchikeys(all_cids)
198+
rows = []
199+
for cid in all_cids:
200+
ik = cid2ik.get(cid)
201+
if not ik:
202+
continue
203+
ostrs, osrcs = odor_by_cid.get(cid, ([], set()))
204+
thr_pairs = thr_by_cid.get(cid, [])
205+
rows.append({"inchikey": ik,
206+
"odor": "\n".join(sorted(set(ostrs), key=len)) or None,
207+
"odor_source": "; ".join(sorted(osrcs)) or None,
208+
"odor_threshold_ppm": _parse_threshold_ppm(thr_pairs),
209+
"odor_threshold_note": "\n".join(dict.fromkeys(t for _, t in thr_pairs)) or None,
210+
"odor_threshold_source": "; ".join(sorted({s for s, _ in thr_pairs})) or None})
211+
return rows
212+
213+
141214
COLS = ["inchikey", "odor", "odor_source", "odor_threshold_ppm",
142215
"odor_threshold_note", "odor_threshold_source"]
143216

@@ -146,9 +219,23 @@ def fetch_record(inchikey):
146219
ap.add_argument("--molecules", help="CSV/parquet with 'smiles' or 'inchikey' "
147220
"(default: flavor_volatiles.csv)")
148221
ap.add_argument("--out", default="odor_notes.parquet", help="output (merged if it exists)")
222+
ap.add_argument("--pubchem-all", action="store_true",
223+
help="pull the ENTIRE public-domain PubChem odor set via the annotations API")
149224
ap.add_argument("smiles", nargs="*", help="SMILES to test-print (no build)")
150225
a = ap.parse_args()
151226

227+
if a.pubchem_all: # whole public-domain odor set, via the annotations API (cheap)
228+
rows = build_from_pubchem_annotations()
229+
new = pd.DataFrame(rows, columns=COLS)
230+
if Path(a.out).exists():
231+
old = pd.read_parquet(a.out)
232+
new = pd.concat([old, new], ignore_index=True).drop_duplicates("inchikey", keep="last")
233+
new = new.reindex(columns=COLS)
234+
new.to_parquet(a.out)
235+
print(f"{a.out}: {len(new)} molecules ({int(new['odor'].notna().sum())} odor, "
236+
f"{int(new['odor_threshold_ppm'].notna().sum())} threshold); public-domain")
237+
sys.exit(0)
238+
152239
if a.smiles: # test mode
153240
for smi in a.smiles:
154241
mol = Chem.MolFromSmiles(smi)

training/workbench.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -270,11 +270,15 @@ <h2>Aroma</h2>
270270
function render(p, neighbors, svg, names){
271271
names = names || {};
272272
$('structure').innerHTML = svg || '';
273-
$('cname').textContent = names.common || '';
274-
$('cname').style.display = names.common ? 'block' : 'none';
273+
const common = names.common || '', iupac = names.iupac || '';
274+
// don't show the same string twice: PubChem's "Title" for a flattened structure is often
275+
// the systematic name, so common can equal the IUPAC name — show it once in that case.
276+
const dupe = common && iupac && common.trim().toLowerCase() === iupac.trim().toLowerCase();
277+
$('cname').textContent = common;
278+
$('cname').style.display = common ? 'block' : 'none';
275279
$('smiles').textContent = p.smiles;
276-
$('iupac').textContent = names.iupac ? ('IUPAC: ' + names.iupac) : '';
277-
$('iupac').style.display = names.iupac ? 'block' : 'none';
280+
$('iupac').textContent = (iupac && !dupe) ? ('IUPAC: ' + iupac) : '';
281+
$('iupac').style.display = (iupac && !dupe) ? 'block' : 'none';
278282
const inDomain = !(p.applicability && p.applicability.in_domain === false);
279283
const db = $('domainBanner');
280284
db.style.display = inDomain ? 'none' : 'block';

0 commit comments

Comments
 (0)