pino-source-code / scripts /enrich_registry.py
mattbitzesty's picture
feat(scraper): TGSC enrichment with mmHg->Pa conversion and priority registry updates
5c9c324
Raw
History Blame Contribute Delete
6.91 kB
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())