| from __future__ import annotations |
|
|
| import logging |
| from typing import Any |
|
|
| import numpy as np |
| from thermo.unifac import DOUFIP2006, DOUFSG, UNIFAC |
|
|
| from ..models import Formula |
| from .unifac import DOUFSG_AVAILABLE_GROUPS, apply_structural_fallback, validate_subgroups |
|
|
| logger = logging.getLogger("pino.thermo.vle") |
|
|
|
|
| class VLECalculator: |
| """Compute non-ideal VLE via Dortmund-Modified UNIFAC with dynamic fallback.""" |
|
|
| def __init__(self, formula: Formula) -> None: |
| self.formula = formula |
| self._cache: dict[tuple[float, ...], np.ndarray] = {} |
| self._has_unifac = False |
| self.unifac: UNIFAC | None = None |
| self._setup_unifac() |
|
|
| def _setup_unifac(self) -> None: |
| """Build a UNIFAC object from the ingredient DDB UNIFAC groups.""" |
| groups: list[dict[int, int]] = [] |
| for ing in self.formula.ingredients: |
| raw = ing.unifac_groups or {} |
| |
| int_groups: dict[int, int] = {} |
| for gid, count in raw.items(): |
| try: |
| key = int(gid) |
| except (ValueError, TypeError): |
| logger.warning("Skipping non-integer UNIFAC group id %s", gid) |
| continue |
| int_groups[key] = int(count) |
|
|
| valid, missing = validate_subgroups(int_groups) |
| if not valid: |
| cleaned = apply_structural_fallback(int_groups, missing) |
| if not cleaned: |
| logger.warning( |
| "UNIFAC unavailable for %s (CAS %s); activity coefficients default to 1.0", |
| ing.name, ing.cas |
| ) |
| groups.append({}) |
| continue |
| int_groups = {int(k): int(v) for k, v in cleaned.items()} |
|
|
| groups.append(int_groups) |
|
|
| |
| self._has_unifac = all(bool(g) for g in groups) and len(groups) == len(self.formula.ingredients) |
| if not self._has_unifac: |
| logger.debug("UNIFAC unavailable for formula; activity coefficients default to 1.0") |
| return |
|
|
| try: |
| self.unifac = UNIFAC.from_subgroups( |
| T=self.formula.temperature_k, |
| xs=[1.0 / len(self.formula.ingredients)] * len(self.formula.ingredients), |
| chemgroups=groups, |
| version=1, |
| interaction_data=DOUFIP2006, |
| subgroups=DOUFSG, |
| ) |
| except Exception as exc: |
| logger.warning("UNIFAC setup failed (%s); falling back to ideal gammas=1", exc) |
| self._has_unifac = False |
| self.unifac = None |
|
|
| def activity_coefficients(self, x: np.ndarray) -> np.ndarray: |
| """Return gamma_i for liquid mole fractions x using UNIFAC.""" |
| key = tuple(np.round(x, 12).tolist()) |
| if key in self._cache: |
| return self._cache[key] |
|
|
| if not self._has_unifac or self.unifac is None: |
| gammas = np.ones_like(x, dtype=float) |
| else: |
| try: |
| new_unifac = self.unifac.to_T_xs( |
| T=self.formula.temperature_k, |
| xs=x.tolist(), |
| ) |
| gammas = np.array(new_unifac.gammas(), dtype=float) |
| except Exception as exc: |
| logger.warning("UNIFAC gamma calculation failed (%s); using gamma=1", exc) |
| gammas = np.ones_like(x, dtype=float) |
|
|
| self._cache[key] = gammas |
| return gammas |
|
|
| def partial_pressures(self, x: np.ndarray) -> np.ndarray: |
| """P_i = gamma_i * x_i * P_i^sat (non-ideal Raoult).""" |
| gamma = self.activity_coefficients(x) |
| p_sat = np.array([i.vapor_pressure_pa for i in self.formula.ingredients], dtype=float) |
| return gamma * x * p_sat |
|
|
| def gas_mole_fractions(self, x: np.ndarray) -> np.ndarray: |
| """y_i = P_i / P_total (assuming gas phase ideal at ambient pressure).""" |
| p_partial = self.partial_pressures(x) |
| p_total = p_partial.sum() |
| if p_total <= 0: |
| return np.zeros_like(x) |
| return p_partial / p_total |
|
|
| def gas_concentrations_mg_m3(self, x: np.ndarray) -> np.ndarray: |
| """Convert gas mole fractions to mass concentrations at ambient T and P.""" |
| y = self.gas_mole_fractions(x) |
| |
| R = 8.314462618 |
| c_total = self.formula.ambient_pressure_pa / (R * self.formula.temperature_k) |
| mws = np.array([i.molecular_weight for i in self.formula.ingredients], dtype=float) |
| |
| return y * c_total * mws * 1000.0 |
|
|
| def fugacity_ratios(self, x: np.ndarray) -> np.ndarray: |
| """Driving force for evaporation: f_i^L / (P * phi_i) ≈ gamma_i * x_i * P_i^sat / P.""" |
| return self.partial_pressures(x) / self.formula.ambient_pressure_pa |
|
|