File size: 6,731 Bytes
b233cf7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 | from __future__ import annotations
import argparse
import json
import logging
import sqlite3
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any
import pandas as pd
import pyrfume
from rdkit import Chem
from rdkit.Chem import Descriptors
from pino.registry import AromaRegistry
logger = logging.getLogger("pino.palette_expander")
PROHIBITED_CAS = {
"81-15-2": "Musk xylene",
"83-66-9": "Musk ambrette",
}
def _load_pyrfume_molecules(archives: list[str]) -> pd.DataFrame:
"""Load and deduplicate molecule metadata from pyrfume archives."""
frames = []
for archive in archives:
try:
df = pyrfume.load_data(f"{archive}/molecules.csv")
if df is None or df.empty:
continue
df = df.reset_index().rename(columns={"index": "CID"})
df["archive"] = archive
frames.append(df)
logger.info("Loaded %s: %d molecules", archive, len(df))
except Exception as exc:
logger.warning("Could not load %s: %s", archive, exc)
if not frames:
raise RuntimeError("No pyrfume archives could be loaded")
combined = pd.concat(frames, ignore_index=True)
combined = combined.drop_duplicates(subset=["IsomericSMILES"], keep="first")
return combined
def _validate_molecule(smiles: str, name: str | None = None) -> dict[str, Any] | None:
"""Offline SMILES validation through RDKit + ugropy + thermo."""
mol = Chem.MolFromSmiles(smiles)
if mol is None:
return None
try:
mw = Descriptors.MolWt(mol)
except Exception:
return None
if mw < 50 or mw > 600:
return None
try:
record = AromaRegistry.build_record_from_smiles(smiles, name=name)
except Exception as exc:
logger.debug("Validation failed for %s: %s", smiles, exc)
return None
return record
def _harvest_worker(args: tuple[int, pd.Series]) -> dict[str, Any] | None:
"""Worker for a single molecule: validate and build a registry record."""
idx, row = args
smiles = row.get("IsomericSMILES")
if not isinstance(smiles, str) or not smiles:
return None
name = row.get("name") or row.get("IUPACName")
record = _validate_molecule(smiles, name=name)
if record is None:
return None
cas = record.get("cas", "")
if cas in PROHIBITED_CAS:
logger.info("Skipping prohibited compound %s (%s)", cas, PROHIBITED_CAS[cas])
return None
record["source"] = f"pyrfume:{row.get('archive', 'unknown')}"
return record
def expand_registry(
registry_path: str | Path,
target: int = 300,
max_workers: int = 8,
batch_size: int = 50,
) -> dict[str, int]:
"""
Harvest aroma molecules from pyrfume, validate them, and insert into the
SQLite registry until `target` new validated records are reached.
This routine is checkpoint-resilient: existing records are skipped on
restart, and successful inserts are committed in batches.
"""
registry = AromaRegistry(Path(registry_path))
existing = {
r["smiles"]
for r in registry._conn.execute("SELECT smiles FROM aroma_chemicals").fetchall()
}
logger.info("Registry currently has %d records; skipping known SMILES", len(existing))
archives = ["goodscents", "leffingwell", "arctander_1960", "fragrancedb"]
molecules = _load_pyrfume_molecules(archives)
# Drop already-known SMILES before doing any expensive validation.
molecules = molecules[~molecules["IsomericSMILES"].isin(existing)]
logger.info("Candidate molecules after deduplication and skip: %d", len(molecules))
inserted = 0
rejected = 0
skipped = 0
pending: list[dict[str, Any]] = []
def _commit_batch(records: list[dict[str, Any]]) -> int:
"""Insert a batch of validated records and return the count inserted."""
count = 0
for record in records:
try:
registry.register(
cas=record["cas"],
name=record["name"],
smiles=record["smiles"],
molecular_weight=record["molecular_weight"],
boiling_point_k=record.get("boiling_point_k"),
vapor_pressure_pa=record.get("vapor_pressure_pa"),
logp=record.get("logp"),
unifac_groups_json=record.get("unifac_groups", {}),
)
count += 1
except Exception as exc:
logger.warning("DB insert failed for %s: %s", record.get("cas"), exc)
return count
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(_harvest_worker, (i, row)): (i, row)
for i, row in molecules.iterrows()
}
for future in as_completed(futures):
record = future.result()
if record is None:
rejected += 1
continue
if record["smiles"] in existing:
skipped += 1
continue
pending.append(record)
existing.add(record["smiles"])
if len(pending) >= batch_size:
inserted += _commit_batch(pending)
pending = []
logger.info(
"Checkpoint: inserted=%d rejected=%d skipped=%d | registry total ~%d",
inserted,
rejected,
skipped,
len(existing),
)
if inserted >= target:
break
# Final flush.
if pending:
inserted += _commit_batch(pending)
registry.close()
return {"inserted": inserted, "rejected": rejected, "skipped": skipped}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Expand PINO aroma-chemical registry from pyrfume")
parser.add_argument("--registry", default="src/pino/registry.db", help="Path to SQLite registry")
parser.add_argument("--target", type=int, default=300, help="Target number of new validated records")
parser.add_argument("--workers", type=int, default=8, help="Parallel validation workers")
parser.add_argument("--batch-size", type=int, default=50, help="SQLite commit batch size")
parser.add_argument("--log-level", default="INFO", help="Logging level")
args = parser.parse_args()
logging.basicConfig(
level=getattr(logging, args.log_level.upper()),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
result = expand_registry(args.registry, args.target, args.workers, args.batch_size)
print(result)
|