from __future__ import annotations """ Targeted TGSC enrichment of the PINO aroma registry. Processes a priority subset of single-chemical fragrance materials, fetches their TGSC data sheets, and writes the extracted boiling point, vapor pressure (mmHg -> Pa), and odor description back into the SQLite registry. """ import argparse import logging import re import sys from pathlib import Path from typing import Any sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from camoufox.sync_api import Camoufox from pino.registry import AromaRegistry from pino.scraper import search_tgsc, parse_tgsc_page_camofox logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) logger = logging.getLogger("enrich_registry") # Priority palette covering top, heart, and base notes. These are the materials # that most strongly influence the VLE dry-down and the optimizer's search space. PRIORITY_CAS = [ "5989-27-5", # d-Limonene (citrus top) "78-70-6", # Linalool (floral/citrus top-heart) "5392-40-5", # Citral (lemon/citrus top) "106-24-1", # Geraniol (rosy floral heart) "106-25-2", # Nerol (citrus-rose heart) "115-95-7", # Linalyl acetate (bergamot/citrus top) "80-56-8", # alpha-Pinene (pine/citrus top) "99-85-4", # Gamma-terpinene (citrus peel) "91-64-5", # Coumarin (sweet hay base) "121-33-5", # Vanillin (sweet gourmand base) "105-95-3", # Ethylene brassylate (musk base) "77-53-2", # Cedrol (cedarwood base) "87-44-5", # Caryophyllene (spicy woody heart) "97-53-0", # Eugenol (spicy clove heart) "119-36-8", # Methyl salicylate (wintergreen top) "118-58-1", # Benzyl salicylate (floral musk base) "1222-05-5", # Galaxolide (musk base) "21145-77-7", # Tonalid (musk base) "4602-84-0", # Farnesol (floral woody base) "7212-44-4", # Nerolidol (woody floral base) "104-67-6", # Gamma-undecalactone (peach lactone base) "120-57-0", # Anisaldehyde (anise top) "124-13-0", # Octanal (citrus aldehyde top) "112-31-2", # Decanal (waxy aldehyde top) "140-11-4", # Benzyl acetate (jasmin floral heart) "141-12-8", # Linalyl acetate (duplicate alias) "24851-98-7", # Methyl dihydrojasmonate (jasmine heart) "127-51-5", # alpha-Isomethyl ionone (powdery floral heart) "14901-07-6", # Gamma-methyl ionone (floral heart) "16409-43-1", # Cashmeran (woody musk base) "33704-61-9", # Cashmeran (alternative) - skipped, duplicate ] def _is_single_chemical(cas: str) -> bool: """Natural oils and absolutes have 8000/9000 CAS prefixes; skip them.""" return not re.match(r"^(8\d{3}|9\d{3})", cas) def enrich_priority( registry: AromaRegistry, priority: list[str], limit: int | None = None, ) -> dict[str, Any]: """ Enrich a priority list of CAS entries from TGSC. Returns a summary dict with counts and the list of updated records. """ rows = registry._conn.execute( "SELECT cas, name FROM aroma_chemicals WHERE cas IN (" + ",".join("?" * len(priority)) + ") ORDER BY cas", tuple(priority), ).fetchall() if limit is not None: rows = rows[:limit] logger.info("Enriching %d priority materials from TGSC", len(rows)) enriched: list[dict[str, Any]] = [] skipped = 0 failed = 0 with Camoufox(headless=True) as browser: for cas, name in rows: if not _is_single_chemical(cas): skipped += 1 continue try: candidates = search_tgsc(name, browser=browser) except Exception as exc: logger.warning("Search failed for %s (%s): %s", cas, name, exc) failed += 1 continue if not candidates: logger.warning("No TGSC results for %s (%s)", cas, name) failed += 1 continue scraped = None for candidate in candidates: try: scraped = parse_tgsc_page_camofox(candidate["url"], browser=browser) except Exception as exc: logger.debug("Scrape failed for %s: %s", candidate["url"], exc) continue if scraped.get("vapor_pressure_pa") is not None: scraped["url"] = candidate["url"] scraped["name"] = candidate["name"] break if scraped is None or scraped.get("vapor_pressure_pa") is None: failed += 1 continue registry._conn.execute( """ UPDATE aroma_chemicals SET boiling_point_k = COALESCE(?, boiling_point_k), vapor_pressure_pa = COALESCE(?, vapor_pressure_pa), odor_description = COALESCE(?, odor_description) WHERE cas = ? """, ( scraped.get("boiling_point_k"), scraped.get("vapor_pressure_pa"), scraped.get("odor_description", ""), cas, ), ) registry._conn.commit() enriched.append({"cas": cas, "name": name, **scraped}) logger.info( "Enriched %s (%s): VP=%.4f Pa, BP=%.2f K, odor=%r", cas, name, scraped["vapor_pressure_pa"], scraped["boiling_point_k"] or 0.0, scraped.get("odor_description", "")[:60], ) return { "enriched": len(enriched), "skipped": skipped, "failed": failed, "records": enriched, } def main() -> int: parser = argparse.ArgumentParser(description="Targeted TGSC registry enrichment") parser.add_argument( "--registry", default="src/pino/registry.db", help="Path to the SQLite aroma registry", ) parser.add_argument( "--limit", type=int, default=None, help="Process only the first N priority materials", ) parser.add_argument( "--priority", default=None, help="Comma-separated list of CAS numbers to enrich instead of the default priority list", ) parser.add_argument( "--log-level", default="INFO", help="Logging level", ) args = parser.parse_args() logging.getLogger().setLevel(getattr(logging, args.log_level.upper(), logging.INFO)) registry = AromaRegistry(args.registry) priority = [c.strip() for c in args.priority.split(",")] if args.priority else PRIORITY_CAS summary = enrich_priority(registry, priority, limit=args.limit) print(summary) return 0 if __name__ == "__main__": raise SystemExit(main())