build: lower Python requirement to >=3.10 for official PyTorch image compatibility
06b7dd3 unverified | 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 | |
| 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}") | |
| 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") | |
| 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() | |
| def avg_molecular_weight(self) -> float: | |
| return float(np.dot(self.mole_fractions, [i.molecular_weight for i in self.ingredients])) | |
| 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, | |
| } | |