# =========================================================== problem definition
def unpack(e):
"""(names, y1, y2, D_AB, D_AC, D_BC) for a Table-1 example, mole fractions."""
r = BASE.loc[e]
y1 = np.array([r.p1_A, r.p1_B, r.p1_C]) / P_MM
y2 = np.array([r.p2_A, r.p2_B, r.p2_C]) / P_MM
return (r.gas_A, r.gas_B, r.gas_C), y1, y2, r.D_AB, r.D_AC, r.D_BC
def Dmat(DAB, DAC, DBC):
return np.array([[np.nan, DAB, DAC], [DAB, np.nan, DBC], [DAC, DBC, np.nan]])
# ================================================== the two mixture rules
def wilke_rule(y, i, D):
"""Eq. (6)/(25): weighted HARMONIC mean of the binaries."""
return (1.0 - y[i]) / sum(y[j] / D[i, j] for j in range(3) if j != i)
def arith_rule(y, i, D):
"""Null baseline: weighted ARITHMETIC mean. The paper's own printed
Discussion (J. H. Arnold, journal p. 104) says Wilke's expression 'is simply
a weighted harmonic mean, as opposed to the weighted arithmetic mean
suggested by Hougen and Watson; the latter is badly in error in some cases'.
Same weights y_j, arithmetic instead of harmonic. Hougen and Watson's own
text was NOT consulted; this is the form Arnold's sentence names."""
return sum(y[j] * D[i, j] for j in range(3) if j != i) / (1.0 - y[i])
# ================================================== exact route 1: closed form
def exact_closed(NA, NB, y1, DAB, DAC, DBC, x=XLEN):
"""(y_A, y_B) at x from the analytic solution of the linear SM system."""
NA, NB = np.asarray(NA, float), np.asarray(NB, float)
yA1, yB1, yC1 = y1
Nt = NA + NB
lamC = (NA / DAC + NB / DBC) / C_TOT # the y_C eigenmode
lam2 = Nt / (C_TOT * DAB) # the A-B eigenmode
Q = NA * NB * (1.0 / DAC - 1.0 / DBC)
yC = yC1 * np.exp(lamC * x)
d = lamC - lam2
fp = np.where(d != 0, -Q * yC1 / (C_TOT * np.where(d == 0, 1.0, d)), np.nan)
f = (NB * yA1 - NA * yB1 - fp) * np.exp(lam2 * x) + fp * np.exp(lamC * x)
yA = np.where(Nt != 0, (f + NA * (1.0 - yC)) / np.where(Nt == 0, 1.0, Nt), np.nan)
return yA, 1.0 - yC - yA
# ============================================ exact route 2: matrix exponential
def _matfun2(M, t, f, fp):
"""f(M t) for a batch of 2x2 matrices, from the two eigenvalues.
Any analytic f satisfies f(A) = f0 I + f1 A with
f1 = (f(l1)-f(l2))/(l1-l2) and f0 = (l1 f(l2) - l2 f(l1))/(l1-l2),
and the confluent limit uses f'. No inversion of M anywhere, which is what
lets the equimolal case N_t = 0 -- where M is exactly singular -- be
evaluated instead of returning NaN."""
A = M * t
a, b, c, d = A[..., 0, 0], A[..., 0, 1], A[..., 1, 0], A[..., 1, 1]
tr, det = a + d, a * d - b * c
disc = np.sqrt((tr / 2) ** 2 - det + 0j)
l1, l2 = tr / 2 + disc, tr / 2 - disc
dl = l1 - l2
near = np.abs(dl) < 1e-12 * (np.abs(l1) + np.abs(l2) + 1e-300)
dls = np.where(near, 1.0, dl)
F1, F2 = f(l1), f(l2)
f1 = np.where(near, fp(l1), (F1 - F2) / dls)
f0 = np.where(near, F1 - l1 * fp(l1), (l1 * F2 - l2 * F1) / dls)
I = np.zeros(A.shape, dtype=complex)
I[..., 0, 0] = I[..., 1, 1] = 1.0
return (f0[..., None, None] * I + f1[..., None, None] * A).real
def _phi1(z):
"""(e^z - 1)/z, analytic at 0."""
small = np.abs(z) < 1e-8
zz = np.where(small, 1.0, z)
return np.where(small, 1.0 + z / 2 + z ** 2 / 6, np.expm1(zz) / zz)
def _phi1p(z):
small = np.abs(z) < 1e-6
zz = np.where(small, 1.0, z)
return np.where(small, 0.5 + z / 3, (zz * np.exp(zz) - np.expm1(zz)) / zz ** 2)
def exact_expm(NA, NB, y1, DAB, DAC, DBC, x=XLEN):
NA = np.atleast_1d(np.asarray(NA, float))
NB = np.atleast_1d(np.asarray(NB, float))
M = np.empty(NA.shape + (2, 2))
M[..., 0, 0] = NA / DAC + NB / DAB
M[..., 0, 1] = NA / DAC - NA / DAB
M[..., 1, 0] = NB / DBC - NB / DAB
M[..., 1, 1] = NB / DBC + NA / DAB
M /= C_TOT
b = np.stack([-NA / DAC, -NB / DBC], -1) / C_TOT
# variation of constants without inverting M:
# y(x) = exp(Mx) y_1 + x phi_1(Mx) b, phi_1(z) = (e^z - 1)/z
E = _matfun2(M, x, np.exp, np.exp)
Ph = _matfun2(M, x, _phi1, _phi1p)
ye = (np.einsum("...ij,...j->...i", E, y1[:2])
+ x * np.einsum("...ij,...j->...i", Ph, b))
return ye[..., 0], ye[..., 1]
# ================================================================ root machinery
def all_roots(fun, grid):
"""Every sign change of a vectorised fun on grid, each bracketed by brentq.
Extrema and thresholds are ROOT-FOUND on this page, never sampled."""
v = np.asarray(fun(grid), float)
ok = np.isfinite(v)
out = []
for a, b, fa, fb, oa, ob in zip(grid[:-1], grid[1:], v[:-1], v[1:], ok[:-1], ok[1:]):
if oa and ob and fa * fb < 0:
out.append(brentq(lambda z: float(np.atleast_1d(fun(np.array([z])))[0]),
a, b, xtol=1e-300, rtol=8.9e-16))
return out
def eq23_NB(NA, y1, y2, DAC, DBC, x=XLEN):
"""Wilke eq. (23) solved for N_B - an exact integral, not an approximation."""
rhs = C_TOT / x * np.log(y2[2] / y1[2])
return (rhs - np.asarray(NA, float) / DAC) * DBC
def exact_solve(e, route=exact_closed, lim=2e-2, n=200001, x=XLEN):
"""All exact (N_A, N_B) for a Table-1 example. Eq. (23) removes one unknown,
so this is a 1-D scan-and-bracket over N_A: deterministic, no seed, no
continuation, and it reports how many roots exist rather than assuming one."""
_, y1, y2, DAB, DAC, DBC = unpack(e)
def F(NA):
NA = np.atleast_1d(np.asarray(NA, float))
NB = eq23_NB(NA, y1, y2, DAC, DBC, x)
return route(NA, NB, y1, DAB, DAC, DBC, x)[0] - y2[0]
grid = np.linspace(-lim, lim, n)
grid = grid[np.abs(grid) > 1e-12]
return [(r, float(eq23_NB(r, y1, y2, DAC, DBC, x))) for r in all_roots(F, grid)]