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)