pino-source-code / src /pino /ingest_formulas.py
mattbitzesty's picture
feat(data): generate OAV targets in dataset builder and validate UNIFAC subgroups
884ed70 unverified
Raw
History Blame Contribute Delete
10.9 kB
from __future__ import annotations
"""
Literature fragrance formula ingestion and normalization.
Reads raw recipe manifests (e.g., from Poucher, Arctander, Calkin & Jellinek),
recursively unpacks proprietary sub-bases into pure CAS constituents, and scales
all masses to strict unit weight fractions (Σ w_i = 1.0).
The output is directly consumable by the PINO optimizer and verifier.
"""
import json
import logging
from collections import defaultdict
from pathlib import Path
from typing import Any
from .registry import AromaRegistry
from .thermo.unifac import validate_subgroups
logger = logging.getLogger("pino.ingest_formulas")
ETHANOL_CAS = "64-17-5"
def _recursive_unpack(
component: dict[str, Any],
accumulator: dict[str, float],
parent_multiplier: float = 1.0,
) -> None:
"""
Recursively unpack a component into pure CAS mass contributions.
Parameters
----------
component: dict
A recipe component with keys 'type', 'amount', 'cas'/'name'/'composition'.
accumulator: dict
Running map of CAS -> absolute mass.
parent_multiplier: float
Scale factor inherited from parent bases (product of ancestor amounts).
"""
comp_type = component.get("type", "pure")
amount = float(component.get("amount", 0.0)) * parent_multiplier
if comp_type == "pure":
cas = component.get("cas")
if not cas:
raise ValueError(f"Pure component missing CAS: {component}")
accumulator[cas] += amount
elif comp_type == "sub_base":
base_composition = component.get("composition") or []
if not base_composition:
logger.warning("Sub-base '%s' has no composition; skipping", component.get("name"))
return
# Determine how sub-base internal fractions are expressed.
sub_total = sum(float(sub.get("amount", 0.0)) for sub in base_composition)
if sub_total <= 0:
raise ValueError(f"Sub-base '{component.get('name')}' composition sums to <= 0")
for sub in base_composition:
sub_component = dict(sub)
sub_component["amount"] = float(sub.get("amount", 0.0)) / sub_total * amount
_recursive_unpack(sub_component, accumulator, parent_multiplier=1.0)
else:
raise ValueError(f"Unknown component type: {comp_type}")
def normalize_and_unpack_recipe(raw_recipe_path: str | Path) -> dict[str, Any]:
"""
Load a raw literature recipe and return a normalized pure-CAS formula.
The returned dict has:
- formula_id, name, source
- components: list of {"cas", "weight_fraction"} with Σ = 1.0
"""
path = Path(raw_recipe_path)
data = json.loads(path.read_text(encoding="utf-8"))
flat_composition: dict[str, float] = defaultdict(float)
for comp in data.get("components", []):
_recursive_unpack(comp, flat_composition)
absolute_sum = sum(flat_composition.values())
if absolute_sum <= 0:
raise ValueError(f"Recipe {data.get('formula_id')} has zero total mass")
normalized_components = []
for cas in sorted(flat_composition.keys()):
weight_fraction = round(flat_composition[cas] / absolute_sum, 6)
normalized_components.append({"cas": cas, "weight_fraction": weight_fraction})
# Micro-adjust to ensure exact 1.0 after rounding.
total_fraction = sum(c["weight_fraction"] for c in normalized_components)
if total_fraction != 1.0 and normalized_components:
normalized_components[0]["weight_fraction"] = round(
normalized_components[0]["weight_fraction"] + (1.0 - total_fraction), 6
)
return {
"formula_id": data.get("formula_id"),
"name": data.get("name"),
"source": data.get("source"),
"components": normalized_components,
}
def load_literature_manifest(manifest_path: str | Path) -> list[dict[str, Any]]:
"""Load a JSON file that may be either a single recipe or a list of recipes."""
path = Path(manifest_path)
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, list):
return data
return [data]
def normalize_and_unpack_recipe_from_dict(data: dict[str, Any]) -> dict[str, Any]:
"""In-memory version of normalize_and_unpack_recipe for a single recipe dict."""
flat_composition: dict[str, float] = defaultdict(float)
for comp in data.get("components", []):
_recursive_unpack(comp, flat_composition)
absolute_sum = sum(flat_composition.values())
if absolute_sum <= 0:
raise ValueError(f"Recipe {data.get('formula_id')} has zero total mass")
normalized_components = []
for cas in sorted(flat_composition.keys()):
weight_fraction = round(flat_composition[cas] / absolute_sum, 6)
normalized_components.append({"cas": cas, "weight_fraction": weight_fraction})
total_fraction = sum(c["weight_fraction"] for c in normalized_components)
if total_fraction != 1.0 and normalized_components:
normalized_components[0]["weight_fraction"] = round(
normalized_components[0]["weight_fraction"] + (1.0 - total_fraction), 6
)
return {
"formula_id": data.get("formula_id"),
"name": data.get("name"),
"source": data.get("source"),
"components": normalized_components,
}
def _build_registry_cas_sets(registry: AromaRegistry) -> tuple[set[str], set[str]]:
"""Return (known_cas, unifac_valid_cas) from the registry."""
rows = registry._conn.execute(
"SELECT cas, name, unifac_groups FROM aroma_chemicals"
).fetchall()
known_cas = {row[0] for row in rows}
unifac_valid_cas: set[str] = set()
for cas, name, groups_json in rows:
try:
groups = json.loads(groups_json or "{}")
except Exception:
groups = {}
valid, _ = validate_subgroups(groups)
if valid:
unifac_valid_cas.add(cas)
return known_cas, unifac_valid_cas
def _filter_and_rescale_components(
components: list[dict[str, Any]],
keep_cas: set[str],
formula_id: str | None,
) -> list[dict[str, Any]]:
"""Drop filtered components and rescale the remaining weight fractions to 1.0."""
if len(components) == len(keep_cas):
return components
filtered = [c for c in components if c["cas"] in keep_cas]
total = sum(c["weight_fraction"] for c in filtered)
if total <= 0:
logger.warning("Recipe %s has no valid components after filtering", formula_id)
return []
for c in filtered:
c["weight_fraction"] = round(c["weight_fraction"] / total, 6)
adj = sum(c["weight_fraction"] for c in filtered)
if filtered and adj != 1.0:
filtered[0]["weight_fraction"] = round(
filtered[0]["weight_fraction"] + (1.0 - adj), 6
)
return filtered
def normalize_manifest(
manifest_path: str | Path,
registry_path: str | Path | None = None,
*,
strict_unifac: bool = True,
) -> list[dict[str, Any]]:
"""
Normalize every recipe in a manifest and validate each CAS against the registry.
Recipes that contain unknown CAS numbers are flagged but still returned so
the caller can decide whether to drop, map, or back-fill them.
If strict_unifac is True, components whose stored UNIFAC subgroups are not
available in DOUFSG are rejected (ethanol, the canonical solvent, is always
allowed). Weight fractions are re-scaled after any filtering.
"""
registry = AromaRegistry(registry_path)
known_cas, unifac_valid_cas = _build_registry_cas_sets(registry)
results = []
for raw_recipe in load_literature_manifest(manifest_path):
tmp = dict(raw_recipe)
normalized = normalize_and_unpack_recipe_from_dict(tmp)
unknown = [c["cas"] for c in normalized["components"] if c["cas"] not in known_cas]
if unknown:
logger.warning(
"Recipe %s contains unknown CAS entries: %s",
normalized["formula_id"],
unknown,
)
normalized["registry_status"] = "unknown_cas"
normalized["unknown_cas"] = unknown
else:
normalized["registry_status"] = "ok"
if strict_unifac:
unifac_missing = [
c["cas"] for c in normalized["components"]
if c["cas"] not in unifac_valid_cas and c["cas"] != ETHANOL_CAS
]
if unifac_missing:
logger.warning(
"Recipe %s contains components without valid DOUFSG subgroups: %s",
normalized["formula_id"],
unifac_missing,
)
normalized["unifac_status"] = "invalid_subgroups"
normalized["unifac_invalid_cas"] = unifac_missing
else:
normalized["unifac_status"] = "ok"
else:
normalized["unifac_status"] = "skipped"
# Build the set of components to keep after registry + UNIFAC filtering.
keep_cas = {
c["cas"] for c in normalized["components"]
if c["cas"] in known_cas
and (not strict_unifac or c["cas"] in unifac_valid_cas or c["cas"] == ETHANOL_CAS)
}
normalized["components"] = _filter_and_rescale_components(
normalized["components"],
keep_cas,
normalized["formula_id"],
)
if not normalized["components"]:
normalized["registry_status"] = "no_valid_components"
results.append(normalized)
return results
def recipe_to_formula_records(recipe: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a normalized recipe into the verifier-friendly formula format."""
return [
{"cas": c["cas"], "weight_fraction": c["weight_fraction"]}
for c in recipe.get("components", [])
]
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Normalize literature fragrance recipes")
parser.add_argument("manifest", help="Path to raw recipe JSON or JSON list")
parser.add_argument("--registry", default="src/pino/registry.db", help="SQLite registry path")
parser.add_argument("--output", help="Optional output JSON path")
parser.add_argument(
"--no-strict-unifac",
action="store_true",
help="Allow components with missing DOUFSG subgroups to pass validation",
)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
results = normalize_manifest(
args.manifest,
registry_path=args.registry,
strict_unifac=not args.no_strict_unifac,
)
out_text = json.dumps(results, indent=2, ensure_ascii=False)
if args.output:
Path(args.output).write_text(out_text, encoding="utf-8")
else:
print(out_text)