| 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 |
|
|
|
|
| |
| |
| |
| _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, |
| ) |
| |
| 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) |
| |
| assert np.isfinite(gamma).all() |
| assert gamma.shape == (len(test_formula.ingredients),) |
| |
| 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", |
| ) |
| |
| 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) |
|
|
| |
| assert (trajectory.n_liquid >= 0).all() |
| |
| total_moles = trajectory.n_liquid.sum(axis=0) |
| assert np.all(np.diff(total_moles) <= 1e-12) |
|
|
| |
| depletion = (trajectory.n_liquid[:, 0] - trajectory.n_liquid[:, -1]) / trajectory.n_liquid[:, 0] |
|
|
| |
| 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] |
|
|
| |
| assert (trajectory.C_gas >= 0).all() |
| |
| assert np.allclose(trajectory.x_liquid.sum(axis=0), 1.0, atol=1e-8) |
| assert (trajectory.x_liquid >= 0).all() |
|
|
| |
| assert (trajectory.OAV >= 0).all() |
|
|
| |
| 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) |
| |
| assert np.any(np.abs(gamma - 1.0) > 0.01) |
|
|