File size: 6,881 Bytes
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 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 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
from pino import evaporation, vle, verifier
from pino.models import Formula, Ingredient, IngredientResolutionError
from pino.structures import IngredientResolver, ingredient_from_smiles
from pino.verifier import FormulaVerifier, verify_formula_dict
# Reference data for the classic 5-component accord in an ethanol base.
# Vapor pressures are approximate room-temperature values (Pa) for 25 °C.
# Odor thresholds are rough literature estimates (µg/m³).
_TEST_FORMULA_DATA = {
"temperature_k": 298.15,
"ambient_pressure_pa": 101325.0,
"headspace_volume_m3": 1e-3,
"liquid_volume_m3": 1e-6,
"metadata": {"density_g_ml": 0.9},
"ingredients": [
{
"name": "Ethanol",
"cas": "64-17-5",
"smiles": "CCO",
"molecular_weight": 46.07,
"vapor_pressure_pa": 7900.0,
"boiling_point_k": 351.45,
"odor_threshold_ug_m3": 250000.0,
},
{
"name": "Limonene",
"cas": "138-86-3",
"smiles": "CC1=CCC(C=C1)C(C)C",
"molecular_weight": 136.23,
"vapor_pressure_pa": 195.0,
"boiling_point_k": 449.0,
"odor_threshold_ug_m3": 30.0,
},
{
"name": "Linalool",
"cas": "78-70-6",
"smiles": "CC=C(C)C(CC=C(C)C)O",
"molecular_weight": 154.25,
"vapor_pressure_pa": 22.0,
"boiling_point_k": 471.0,
"odor_threshold_ug_m3": 6.0,
},
{
"name": "Hedione",
"cas": "24851-98-7",
"smiles": "CC/C(=C/C(=O)C(C)C)C(C)C(=O)O",
"molecular_weight": 226.32,
"vapor_pressure_pa": 1.2,
"boiling_point_k": 548.0,
"odor_threshold_ug_m3": 1.0,
},
{
"name": "Galaxolide",
"cas": "1222-05-5",
"smiles": "CC(=O)C1=CC=C(C)C=C1",
"molecular_weight": 258.40,
"vapor_pressure_pa": 0.05,
"boiling_point_k": 623.0,
"odor_threshold_ug_m3": 0.1,
},
{
"name": "Ambroxan",
"cas": "6790-58-5",
"smiles": "CC1CCCC2(C)C1C(C)CC(C)O2",
"molecular_weight": 236.39,
"vapor_pressure_pa": 0.01,
"boiling_point_k": 573.0,
"odor_threshold_ug_m3": 0.02,
},
],
"weight_fractions": [0.70, 0.10, 0.08, 0.06, 0.04, 0.02],
}
@pytest.fixture
def test_formula() -> Formula:
return verify_formula_dict(_TEST_FORMULA_DATA).formula
def test_ingredient_from_smiles() -> None:
ing = ingredient_from_smiles(
name="Limonene",
cas="138-86-3",
smiles="CC1=CCC(C=C1)C(C)C",
vapor_pressure_pa=195.0,
)
assert ing.name == "Limonene"
assert ing.molecular_weight > 0
assert ing.unifac_groups
def test_formula_validation() -> None:
ing = Ingredient(
name="Ethanol",
cas="64-17-5",
smiles="CCO",
molecular_weight=46.07,
vapor_pressure_pa=7900.0,
)
# Sum must be 1.0
with pytest.raises(ValueError):
Formula(
ingredients=(ing,),
weight_fractions=(0.5,),
temperature_k=298.15,
ambient_pressure_pa=101325.0,
)
def test_vle_non_ideal(test_formula: Formula) -> None:
calc = vle.VLECalculator(test_formula)
x = test_formula.mole_fractions
gamma = calc.activity_coefficients(x)
# Gamma should be finite and not all exactly 1.0 if UNIFAC produced groups
assert np.isfinite(gamma).all()
assert gamma.shape == (len(test_formula.ingredients),)
# Partial pressures must be less than or equal to saturation pressures
p_part = calc.partial_pressures(x)
p_sat = np.array([i.vapor_pressure_pa for i in test_formula.ingredients])
assert (p_part <= p_sat * 1.01).all()
def test_evaporation_stiff_solve() -> None:
trajectory = verify_formula_dict(
_TEST_FORMULA_DATA,
t_span=(0.0, 28800.0),
t_eval=np.linspace(0.0, 28800.0, 481),
method="BDF",
)
# Shape checks
assert trajectory.t.shape[0] == 481
n_species = len(_TEST_FORMULA_DATA["ingredients"])
assert trajectory.n_liquid.shape == (n_species, 481)
assert trajectory.x_liquid.shape == (n_species, 481)
assert trajectory.C_gas.shape == (n_species, 481)
assert trajectory.y_gas.shape == (n_species, 481)
assert trajectory.OAV.shape == (n_species, 481)
# No negative moles (mass balance sanity)
assert (trajectory.n_liquid >= 0).all()
# Total liquid moles decrease monotonically over time
total_moles = trajectory.n_liquid.sum(axis=0)
assert np.all(np.diff(total_moles) <= 1e-12)
# Compute true fractional liquid depletion per component
depletion = (trajectory.n_liquid[:, 0] - trajectory.n_liquid[:, -1]) / trajectory.n_liquid[:, 0]
# Top notes (ethanol, limonene) deplete faster than heart/base notes
ethanol_idx, limonene_idx, linalool_idx, _, galax_idx, ambrox_idx = range(6)
assert depletion[ethanol_idx] > depletion[limonene_idx]
assert depletion[limonene_idx] > depletion[galax_idx]
assert depletion[ethanol_idx] > depletion[ambrox_idx]
# Gas concentrations should be non-negative
assert (trajectory.C_gas >= 0).all()
# All mole fractions should stay a valid probability vector
assert np.allclose(trajectory.x_liquid.sum(axis=0), 1.0, atol=1e-8)
assert (trajectory.x_liquid >= 0).all()
# OAV should be non-negative
assert (trajectory.OAV >= 0).all()
# Distinct per-species dynamic skin sink rates are recorded
assert "k_skin_s-1" in trajectory.notes
assert len(trajectory.notes["k_skin_s-1"]) == n_species
def test_verify_and_write(tmp_path: Path) -> None:
formula = verify_formula_dict(_TEST_FORMULA_DATA).formula
v = verifier.FormulaVerifier(formula)
out = v.verify_and_write(tmp_path / "trajectory_output.json", t_eval=np.linspace(0, 28800, 481))
assert out.exists()
data = json.loads(out.read_text())
assert "t_s" in data
assert "x_liquid" in data
assert "C_gas_mg_m3" in data
assert "OAV" in data
assert "species" in data
assert len(data["species"]) == len(_TEST_FORMULA_DATA["ingredients"])
assert data["t_s"][-1] == 28800.0
def test_no_ideal_gas_simplification() -> None:
"""Ensure activity coefficients are not hard-coded to unity."""
trajectory = verify_formula_dict(_TEST_FORMULA_DATA)
calc = vle.VLECalculator(trajectory.formula)
x = trajectory.formula.mole_fractions
gamma = calc.activity_coefficients(x)
# For a non-ideal mixture, at least one gamma should differ from 1.0 by >1%
assert np.any(np.abs(gamma - 1.0) > 0.01)
|