pino-source-code / src /pino /importer.py
mattbitzesty's picture
feat(optimizer): masked MSE, strategy seeds, adversarial IFRA guard; add accords/materials/importer
fd5ba83
Raw
History Blame Contribute Delete
4.9 kB
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()