File size: 8,390 Bytes
b233cf7 ad424e4 b233cf7 ad424e4 | 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 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 | from __future__ import annotations
import logging
from typing import Any
import numpy as np
from scipy.integrate import solve_ivp
from .models import Formula, Trajectory
from .vle import VLECalculator
logger = logging.getLogger("pino.evaporation")
class EvaporationModel:
"""
Stiff ODE model for multi-component fragrance evaporation.
State vector: n_i(t) = moles of component i remaining in the liquid film.
Assumptions:
- Well-mixed thin liquid film of fixed surface area A (m2).
- Evaporation flux J_i = k_i * (f_i^L / P) (mol/m2/s) using liquid fugacity ratios.
- k_i = k_base * (MW_i / MW_ethanol)^alpha / (P_i^sat)^beta as a volatility scaling.
- Gas phase is instantaneously well-mixed with the headspace volume.
"""
def __init__(
self,
formula: Formula,
vle: VLECalculator | None = None,
surface_area_m2: float = 1e-4, # 1 cm^2 film
mass_transfer_coefficient: float = 1e-4, # m/s base
volatility_exponent: float = 0.5,
mw_exponent: float = -0.5,
skin_sink_alpha: float = -6.0, # Potts-Guy intercept (log10 scale)
skin_sink_beta: float = 0.4, # Potts-Guy LogP coefficient
skin_sink_gamma: float = 0.005, # Potts-Guy MW coefficient
skin_sink_max: float = 1e-6, # s^-1 clamp
) -> None:
self.formula = formula
self.vle = vle or VLECalculator(formula)
self.surface_area = surface_area_m2
self.k_base = mass_transfer_coefficient
self.alpha = volatility_exponent
self.beta = mw_exponent
self.skin_sink_alpha = skin_sink_alpha
self.skin_sink_beta = skin_sink_beta
self.skin_sink_gamma = skin_sink_gamma
self.skin_sink_max = skin_sink_max
self._mws = np.array([i.molecular_weight for i in formula.ingredients], dtype=float)
self._p_sat = np.array([i.vapor_pressure_pa for i in formula.ingredients], dtype=float)
self._logp = np.array(
[i.logp if i.logp is not None else 0.0 for i in formula.ingredients], dtype=float
)
self._k_skin = self._compute_skin_sink_rates()
self._n0 = self._initial_moles()
# Terminal film-depletion event for solve_ivp.
self._film_depleted_event = self._make_film_depleted_event()
def _make_film_depleted_event(self):
"""Return a solve_ivp event that fires when the total liquid film is depleted."""
def event(t: float, n: np.ndarray) -> float:
# Triggers when total liquid moles drops below a physical trace limit.
return float(np.maximum(n, 0.0).sum() - 1e-7)
event.terminal = True
return event
def _initial_moles(self) -> np.ndarray:
"""Moles of each component in the initial 1 mL liquid sample."""
# Assume liquid density ~ 0.9 g/mL for fragrance oil (overridden by formula metadata)
total_volume_l = self.formula.liquid_volume_m3 * 1000.0
density_g_ml = self.formula.metadata.get("density_g_ml", 0.9)
total_mass_g = total_volume_l * density_g_ml
w = np.array(self.formula.weight_fractions, dtype=float)
masses_g = w * total_mass_g
return masses_g / self._mws
def _mass_transfer_coefficients(self, x: np.ndarray) -> np.ndarray:
"""Species-specific k_i."""
mw_ethanol = 46.07
scaling = (self._mws / mw_ethanol) ** self.beta
# Lower saturation pressure => higher boiling point => slower evaporation
volatility_scaling = (self._p_sat / 1e5) ** self.alpha
return self.k_base * scaling / (volatility_scaling + 1e-12)
def _compute_skin_sink_rates(self) -> np.ndarray:
"""
Potts-Guy inspired per-species skin absorption rate constant (s^-1).
log10(k_skin) = alpha + beta*logP - gamma*MW
"""
log10_k = (
self.skin_sink_alpha
+ self.skin_sink_beta * self._logp
- self.skin_sink_gamma * self._mws
)
with np.errstate(over="ignore", under="ignore"):
k_skin = 10.0 ** log10_k
return np.clip(k_skin, 0.0, self.skin_sink_max)
def _skin_absorption_rate(self, n: np.ndarray) -> np.ndarray:
"""
First-order skin sink: rate_i = k_skin_i * n_i (mol/s).
Vanishes as n_i -> 0, so it cannot drive moles negative.
"""
return self._k_skin * n
def _surface_coverage(self, n_total: float) -> float:
"""
Smooth, differentiable surface coverage factor that gates all mass transport
as the liquid film approaches total exhaustion. Avoids the n_total -> 0
cliff in the mole-fraction calculation.
"""
return float(np.tanh(n_total / 1e-6))
def _dn_dt(self, n: np.ndarray) -> np.ndarray:
"""Physical d n_i / dt = -theta * (evaporation + skin absorption)."""
# Clamp individual species to a tiny positive floor to keep x_i > 0.
n = np.maximum(n, 1e-30)
n_total = n.sum()
# Smoothly gate the entire derivative as the film dries out.
theta = self._surface_coverage(n_total)
if theta <= 1e-12:
return np.zeros_like(n)
x = n / n_total
fugacity_ratio = self.vle.fugacity_ratios(x)
k_i = self._mass_transfer_coefficients(x)
# flux (mol/m2/s) * area (m2) = mol/s
evaporation = -k_i * self.surface_area * fugacity_ratio
skin_sink = -self._skin_absorption_rate(n)
return theta * (evaporation + skin_sink)
def _rhs(self, t: float, n: np.ndarray) -> np.ndarray:
"""d n_i / dt = - evaporation - skin absorption."""
return self._dn_dt(n)
def solve(
self,
t_span: tuple[float, float] = (0.0, 28800.0),
t_eval: np.ndarray | None = None,
method: str = "BDF",
rtol: float = 1e-6,
atol: float = 1e-12,
film_depletion_threshold: float = 1e-6,
) -> Trajectory:
if t_eval is None:
t_eval = np.linspace(t_span[0], t_span[1], 481)
res = solve_ivp(
self._rhs,
t_span,
self._n0,
method=method,
t_eval=t_eval,
events=self._film_depleted_event,
rtol=rtol,
atol=atol,
dense_output=True,
)
if not res.success:
raise RuntimeError(f"ODE integration failed: {res.message}")
t = res.t
n = res.y
# Clip tiny numerical undershoots to enforce physical positivity.
n = np.maximum(n, 0.0)
x_liquid = np.zeros_like(n)
C_gas = np.zeros_like(n)
y_gas = np.zeros_like(n)
OAV = np.zeros_like(n)
for idx in range(n.shape[1]):
n_col = n[:, idx]
n_total = n_col.sum()
if n_total > 1e-18:
x = n_col / n_total
# Clamp tiny numerical negatives to zero and re-normalize.
x = np.clip(x, 0.0, None)
x = x / x.sum()
else:
x = np.zeros_like(n_col)
x_liquid[:, idx] = x
C_gas[:, idx] = self.vle.gas_concentrations_mg_m3(x)
y_gas[:, idx] = self.vle.gas_mole_fractions(x)
OAV[:, idx] = self._oav(x, C_gas[:, idx])
return Trajectory(
t=t,
n_liquid=n,
x_liquid=x_liquid,
C_gas=C_gas,
y_gas=y_gas,
OAV=OAV,
formula=self.formula,
notes={
"method": method,
"surface_area_m2": self.surface_area,
"k_base_m_s": self.k_base,
"k_skin_s-1": self._k_skin.tolist(),
"n_eval": len(t_eval),
"ode_message": res.message,
},
)
def _oav(self, x: np.ndarray, C_gas: np.ndarray) -> np.ndarray:
"""Odor Activity Value = C_gas_i / odor_threshold_i.
C_gas is in mg/m³. odor_threshold_ug_m3 is in µg/m³.
Convert C_gas to µg/m³ (×1000) before dividing so the units match.
"""
thresholds = np.array(
[i.odor_threshold_ug_m3 or np.nan for i in self.formula.ingredients],
dtype=float,
)
with np.errstate(divide="ignore", invalid="ignore"):
return np.where(thresholds > 0, (C_gas * 1000.0) / thresholds, 0.0)
|