|
15 | 15 | """ |
16 | 16 | import contextlib |
17 | 17 | import glob |
| 18 | +import re |
18 | 19 |
|
19 | 20 | import numpy as np |
20 | 21 | import pandas as pd |
@@ -120,6 +121,69 @@ def _documented_isomer_rows(name_by_skel): |
120 | 121 | return rows |
121 | 122 |
|
122 | 123 |
|
| 124 | +def _pick_name(pr): |
| 125 | + """Best display name from a properties row: common name if there is one, else the IUPAC name. |
| 126 | +
|
| 127 | + Written the long way ON PURPOSE. The obvious `pr.get("common_name") or pr.get("iupac_name")` |
| 128 | + is a real bug here: a missing pandas value is NaN, NaN is TRUTHY, so the `or` returns NaN and |
| 129 | + never falls through to the IUPAC name. That silently left ~500 molecules unnamed even though a |
| 130 | + perfectly good name sat in the table — they rendered as raw SMILES in the grid and on cards.""" |
| 131 | + for key in ("common_name", "iupac_name"): |
| 132 | + v = pr.get(key) |
| 133 | + if isinstance(v, str) and v.strip(): |
| 134 | + return v.strip() |
| 135 | + return None |
| 136 | + |
| 137 | + |
| 138 | +_CAS_INVERTED = re.compile(r"^([A-Za-z0-9\-\[\]\(\)']+), ([0-9A-Za-z\-\(\),+\u00b1\s]+?)-?$") |
| 139 | + |
| 140 | + |
| 141 | +def _uninvert_cas(name): |
| 142 | + """Un-invert CAS-style index names so they read forwards. |
| 143 | +
|
| 144 | + PubChem/CAS list many compounds parent-first — "Carvone, (+-)-", "Limonene, (-)-", |
| 145 | + "Cyclohexanol, 5-methyl-2-(1-methylethenyl)-". That ordering exists for alphabetised print |
| 146 | + indexes and reads backwards to everyone else. Three cases, because they resolve differently: |
| 147 | + stereo descriptor "Carvone, (+-)-" -> "(+-)-Carvone" (parent keeps its case) |
| 148 | + derivative suffix "Linalool, oxide" -> "Linalool oxide" |
| 149 | + "2-Hexen-1-ol, 1-acetate" -> "2-Hexen-1-ol 1-acetate" |
| 150 | + substituent prefix "Cyclohexanol, 5-methyl-" -> "5-methyl-cyclohexanol" |
| 151 | + Anything that doesn't match is returned untouched — a wrong "fix" is worse than none.""" |
| 152 | + if not isinstance(name, str) or ", " not in name: |
| 153 | + return name |
| 154 | + m = _CAS_INVERTED.match(name.strip()) |
| 155 | + if not m: |
| 156 | + return name |
| 157 | + parent, mod = m.group(1), m.group(2).strip().rstrip("-").strip() |
| 158 | + if not mod: |
| 159 | + return parent |
| 160 | + # 1. pure stereo / optical descriptor -> prefix, keep the parent's capitalisation |
| 161 | + if re.fullmatch(r"[\(\[][^)\]]*[\)\]]", mod) or mod.lower() in ("cis", "trans", "d", "l", "dl"): |
| 162 | + return f"{mod}-{parent}" |
| 163 | + # 2. a derivative/functional word -> it's a suffix, not a substituent |
| 164 | + if re.search(r"(acetate|oxide|ester|ether|hydrate|hydrochloride|anhydride|lactone|" |
| 165 | + r"[a-z]+oate|[a-z]+ate|alcohol|aldehyde|ketone|acid)$", mod, re.IGNORECASE): |
| 166 | + return f"{parent} {mod}" |
| 167 | + # 3. otherwise a substituent prefix -> lowercase the parent, which is now mid-name |
| 168 | + return f"{mod}-{parent[0].lower()}{parent[1:]}" |
| 169 | + |
| 170 | + |
| 171 | +def _curated_names(): |
| 172 | + """{skeleton -> human name} from the curated CSVs (flavors + aroma/mouthfeel supplements). |
| 173 | + These are the names a flavorist actually uses — "gamma-nonalactone", "hydroxy-alpha-sanshool" — |
| 174 | + and for the molecules we hand-added they are usually the ONLY good name available.""" |
| 175 | + import csv |
| 176 | + out = {} |
| 177 | + for path in ("flavors.csv", "aroma_supplement.csv", "mouthfeel_supplement.csv"): |
| 178 | + with contextlib.suppress(Exception), open(path, encoding="utf-8") as fh: |
| 179 | + for r in csv.DictReader(fh): |
| 180 | + nm = (r.get("molecule") or "").strip() |
| 181 | + m = Chem.MolFromSmiles((r.get("smiles") or "").strip()) |
| 182 | + if nm and m is not None: |
| 183 | + out.setdefault(Chem.MolToInchiKey(m).split("-")[0], nm) |
| 184 | + return out |
| 185 | + |
| 186 | + |
123 | 187 | def _taste_by_skel(): |
124 | 188 | out = {} |
125 | 189 | try: |
@@ -150,16 +214,21 @@ def _taste_by_skel(): |
150 | 214 | props = _by_skel("properties.parquet", |
151 | 215 | ["common_name", "iupac_name", "melting_point_c", "boiling_point_c"]) |
152 | 216 | taste_doc = _taste_by_skel() |
| 217 | + curated = _curated_names() # human names for the molecules we hand-curated |
153 | 218 | print(f"{len(structs)} molecules; {len(props)} with crawl properties; " |
154 | | - f"{len(taste_doc)} with documented taste", flush=True) |
| 219 | + f"{len(taste_doc)} with documented taste; {len(curated)} curated names", flush=True) |
155 | 220 |
|
156 | 221 | skels, smis, rows, feats = [], [], [], [] |
157 | 222 | for skel, smi in structs.items(): |
158 | 223 | m = Chem.MolFromSmiles(smi) |
159 | 224 | if m is None: |
160 | 225 | continue |
161 | 226 | pr = props.get(skel, {}) |
162 | | - name = pr.get("common_name") or pr.get("iupac_name") |
| 227 | + # Curated FIRST: those names were hand-picked as what a flavorist calls the molecule, |
| 228 | + # and PubChem's "common_name" is often systematic anyway (spilanthol's is |
| 229 | + # "N-(2-Methylpropyl)-2,6,8-decatrienamide"). Only fall through to PubChem when we |
| 230 | + # haven't named it ourselves. |
| 231 | + name = _uninvert_cas(curated.get(skel) or _pick_name(pr)) |
163 | 232 | rows.append({ |
164 | 233 | "inchikey_skel": skel, "smiles": smi, |
165 | 234 | "name": name if isinstance(name, str) else None, |
@@ -221,7 +290,7 @@ def _taste_by_skel(): |
221 | 290 | r["tox_flags"] = ",".join(n for n, col in tox_cols.items() if col[i] >= 0.5) # assays firing >=0.5 |
222 | 291 |
|
223 | 292 | # first-class rows for stereoisomers that differ in documented odor/taste |
224 | | - name_by_skel = {sk: (props.get(sk, {}).get("common_name") or props.get(sk, {}).get("iupac_name")) |
| 293 | + name_by_skel = {sk: _uninvert_cas(curated.get(sk) or _pick_name(props.get(sk, {}))) |
225 | 294 | for sk in structs} |
226 | 295 | iso_rows = _documented_isomer_rows(name_by_skel) |
227 | 296 | if iso_rows: |
|
0 commit comments