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()