feat(data): generate OAV targets in dataset builder and validate UNIFAC subgroups
884ed70 unverified | 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) | |