pino-source-code / tests /test_pino.py
mattbitzesty's picture
feat: Milestone 4 clean code snapshot
b233cf7
Raw
History Blame Contribute Delete
6.88 kB
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)