File size: 4,897 Bytes
fd5ba83 | 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 | 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()
|