from __future__ import annotations """ Formula importer: turn a parts-based human formula into a Pino-ready { cas, weight_fraction } formula by resolving trade names via the materials library and proprietary bases via accords. """ import json import logging from pathlib import Path from typing import Any from .accords import expand_accord_by_name from .materials import is_accord, resolve_material from .registry import AromaRegistry logger = logging.getLogger("pino.importer") def _normalise_name(name: str) -> str: """Strip concentration suffixes like '10%' for library lookup.""" return name.strip().replace(" ", " ") def import_formula( path: str | Path, registry: AromaRegistry | None = None, ) -> dict[str, Any]: """ Import a parts-based formula JSON and return a Pino-ready formula record. The input JSON is a list of {"name": str, "parts": int} records. Trade names are resolved through ``materials.py``; proprietary bases are expanded via the accord library in ``accords.py``. Missing materials that cannot be resolved are returned in the ``unresolved`` list so the caller can decide how to proceed (register them, approximate, or drop). """ path = Path(path) with path.open("r", encoding="utf-8") as f: records = json.load(f) if registry is None: registry = AromaRegistry() formula = [] unresolved = [] total_parts = sum(r.get("parts", 0) for r in records) for r in records: name = r["name"] parts = r.get("parts", 0) if parts <= 0: continue wf_global = parts / total_parts normalised = _normalise_name(name) material = resolve_material(normalised) if material and is_accord(normalised): # Expand the accord and scale to the formula-level weight fraction. accord_items = expand_accord_by_name(material["accord"], wf_global) for item in accord_items: formula.append(item) elif material: # Single material; use CAS if present, otherwise SMILES. cas = material.get("cas") smiles = material.get("smiles") resolved_cas = cas if not resolved_cas and smiles: # Try to register by SMILES on the fly. rec = AromaRegistry.validate_smiles(smiles) if rec: resolved_cas = f"SMILES:{smiles}" else: unresolved.append(name) continue formula.append( { "cas": resolved_cas or name, "name": name, # Keep the original trade name so the resolver can look it up in materials.py "display_name": material.get("name", name), "weight_fraction": wf_global, } ) elif registry.get(normalised): # Already in the registry. rec = registry.get(normalised) if rec is None: unresolved.append(name) continue formula.append( { "cas": rec["cas"], "name": rec["name"], "weight_fraction": wf_global, } ) else: unresolved.append(name) # Normalize weight fractions after accord expansion. total = sum(item["weight_fraction"] for item in formula) if total > 0: for item in formula: item["weight_fraction"] /= total # Aggregate duplicate CAS entries so each ingredient appears once. aggregated: dict[str, dict[str, Any]] = {} for item in formula: cas = item["cas"] if cas in aggregated: aggregated[cas]["weight_fraction"] += item["weight_fraction"] else: aggregated[cas] = item.copy() formula = list(aggregated.values()) # Re-normalize after aggregation. total = sum(item["weight_fraction"] for item in formula) if total > 0: for item in formula: item["weight_fraction"] /= total return { "formula": formula, "unresolved": unresolved, "metadata": {"source": str(path), "total_parts": total_parts}, } def main() -> None: import argparse parser = argparse.ArgumentParser(description="Import a parts-based formula") parser.add_argument("--file", required=True, help="Path to formula JSON") parser.add_argument("--out", help="Path to write Pino-ready formula JSON") args = parser.parse_args() logging.basicConfig(level=logging.INFO) result = import_formula(args.file) print(json.dumps(result, indent=2, ensure_ascii=False)) if args.out: Path(args.out).write_text(json.dumps(result, indent=2, ensure_ascii=False)) if __name__ == "__main__": main()