File size: 3,994 Bytes
b233cf7
 
 
06b7dd3
b233cf7
 
 
 
 
06b7dd3
b233cf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum
from typing import Any

import numpy as np


class Status(str, Enum):
    PASSED = "passed"
    REJECTED = "rejected"
    DEPLETED = "depleted"


class IngredientResolutionError(ValueError):
    """Raised when a raw ingredient cannot be resolved or fragmented."""

    def __init__(self, message: str, cas: str | None = None, smiles: str | None = None) -> None:
        super().__init__(message)
        self.cas = cas
        self.smiles = smiles


@dataclass(frozen=True, slots=True)
class Ingredient:
    """Canonical representation of one formula component."""

    name: str
    cas: str
    smiles: str
    molecular_weight: float  # g/mol
    vapor_pressure_pa: float  # pure-component saturation pressure at 25 °C
    boiling_point_k: float | None = None
    odor_threshold_ug_m3: float | None = None  # used to compute OAV
    logp: float | None = None  # RDKit Crippen LogP
    # UNIFAC group counts: group_id -> integer count
    unifac_groups: dict[str, int] = field(default_factory=dict)
    # Optional constants for Antoine / DIPPR extension
    antoine_coeffs: tuple[float, float, float] | None = None

    def __post_init__(self) -> None:
        if self.molecular_weight <= 0:
            raise ValueError(f"MW must be positive for {self.name}")
        if self.vapor_pressure_pa <= 0:
            raise ValueError(f"vapor_pressure_pa must be positive for {self.name}")


@dataclass(frozen=True, slots=True)
class Formula:
    """A multi-component fragrance formulation."""

    ingredients: tuple[Ingredient, ...]
    weight_fractions: tuple[float, ...]  # must sum to 1.0
    temperature_k: float = 298.15
    ambient_pressure_pa: float = 101325.0
    headspace_volume_m3: float = 1e-3  # 1 L default
    liquid_volume_m3: float = 1e-6  # 1 mL default
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if len(self.ingredients) != len(self.weight_fractions):
            raise ValueError("ingredients and weight_fractions must have the same length")
        total = sum(self.weight_fractions)
        if not (0.999 <= total <= 1.001):
            raise ValueError(f"weight fractions must sum to 1.0, got {total}")
        if self.temperature_k <= 0 or self.ambient_pressure_pa <= 0:
            raise ValueError("temperature and pressure must be positive")

    @property
    def mole_fractions(self) -> np.ndarray:
        """Convert weight fractions to liquid mole fractions."""
        weights = np.array(self.weight_fractions, dtype=float)
        mws = np.array([i.molecular_weight for i in self.ingredients], dtype=float)
        moles = weights / mws
        return moles / moles.sum()

    @property
    def avg_molecular_weight(self) -> float:
        return float(np.dot(self.mole_fractions, [i.molecular_weight for i in self.ingredients]))


@dataclass(frozen=True, slots=True)
class Trajectory:
    """Time-resolved output of the evaporation simulation."""

    t: np.ndarray
    n_liquid: np.ndarray  # absolute moles remaining (n_species, n_times)
    x_liquid: np.ndarray  # shape (n_species, n_times)
    C_gas: np.ndarray  # shape (n_species, n_times)
    y_gas: np.ndarray  # shape (n_species, n_times)
    OAV: np.ndarray  # shape (n_species, n_times)
    formula: Formula
    notes: dict[str, Any] = field(default_factory=dict)

    def to_dict(self) -> dict[str, Any]:
        return {
            "t_s": self.t.tolist(),
            "species": [i.name for i in self.formula.ingredients],
            "n_liquid_mol": self.n_liquid.tolist(),
            "x_liquid": self.x_liquid.tolist(),
            "C_gas_mg_m3": self.C_gas.tolist(),
            "y_gas_mole_fraction": self.y_gas.tolist(),
            "OAV": self.OAV.tolist(),
            "temperature_k": self.formula.temperature_k,
            "ambient_pressure_pa": self.formula.ambient_pressure_pa,
            "notes": self.notes,
        }