bingyan user
SPARK v4 outer-sphere ET demo (continuous-manifold model + alternative mechanisms)
e879062 | """Unified 'master' CV simulator parameterized by theta_super. | |
| One Backward-Euler reaction-diffusion solver whose limits reproduce the | |
| diffusional-family corner mechanisms (Nernst, BV, MHC, EC, EC', CE, EE, ECE). | |
| Reuses the existing grid / diffusion / flux helpers from generate_dataset_diffec | |
| so the numerical scheme matches the established datasets exactly. | |
| Stage M1a: ET1 only (Nernst / BV / MHC). Chemical steps (M1b) and a real | |
| reversible 2nd electron transfer with tracked species C (M1c) are added next. | |
| ET1 rate law (theta_super slots log10_K0_1, log10_reorg_e_1, asym_1): | |
| - reversible (K0 large) -> Nernst boundary condition | |
| - log10_reorg_e_1 >= BV cutoff -> Butler-Volmer, alpha = asym_1 | |
| - else -> symmetric Marcus-Hush (existing MHC integral) | |
| The smooth asymmetric-Marcus unification of BV<->MHC is a focused follow-up; | |
| this interface (et1_rates) is where it will plug in. | |
| """ | |
| from __future__ import annotations | |
| import numpy as np | |
| import scipy.linalg | |
| from scipy.sparse import csr_matrix | |
| from scipy.sparse.linalg import spsolve | |
| from generate_dataset_diffec import ( | |
| gen_grid, ini_conc, ini_coeff, calc_abc_linear, calc_flux, calc_mhc_rates, | |
| DELTA_X, DELTA_THETA, EXPANDING_GRID_FACTOR, SIMULATION_SPACE_MULTIPLE, | |
| ) | |
| from generate_extended_mechanisms import ( | |
| _make_potential_waveform, _clamp_bv_rates, | |
| ) | |
| from manifold.theta_super import ( | |
| SLOT_IDX, REVERSIBLE_K0, BV_REORG_SENTINEL, FLOOR_LOGRATE, | |
| ) | |
| _ON = FLOOR_LOGRATE + 0.5 # a log-rate above this is an "active" step | |
| # Unified continuous ET law: smooth interpolation in log10(reorg) between the | |
| # symmetric Marcus-Hush-Chidsey integral (finite lambda) and the Butler-Volmer | |
| # law (lambda -> large). This removes the old hard reorg-sentinel branch, so the | |
| # Nernst <-> BV <-> MHC electron-transfer sub-type becomes ONE continuous axis | |
| # (the manifold ET coordinate). The crossover sits at high lambda (~10^2.3), where | |
| # symmetric MHC already approaches BV(alpha=0.5), so the two physical limits are | |
| # reproduced and the transition between them is gentle. BV carries the transfer | |
| # coefficient (asymmetry) alpha; symmetric MHC corresponds to alpha=0.5. | |
| LAMBDA_MID = 2.0 # log10(reorg) midpoint of the BV<->MHC crossover | |
| LAMBDA_WIDTH = 0.12 # log10(reorg) crossover width | |
| _W_EPS = 5e-3 # fast-path cutoffs (skip the unused limb; keeps the BV | |
| # sentinel and the MHC sampling range on their exact limbs, | |
| # with the blend confined to the high-lambda BV~MHC region) | |
| def _bv_weight(log10_reorg): | |
| """Smooth BV weight: ->1 at large reorg (BV), ->0 at small reorg (MHC).""" | |
| return 1.0 / (1.0 + np.exp(-(log10_reorg - LAMBDA_MID) / LAMBDA_WIDTH)) | |
| def _unpack(theta_super): | |
| v = np.asarray(theta_super, dtype=np.float64).reshape(-1) | |
| return {nm: v[i] for nm, i in SLOT_IDX.items()} | |
| # The legacy calc_mhc_rates (degree-50 Gauss-Hermite) is non-smooth in reorg | |
| # (~9% step noise) and inflates/overflows at large reorg, which showed up as a | |
| # pseudo-seam in the blended ET axis. We use the SAME MHC integrand at a higher, | |
| # exp-clipped quadrature degree (smooth + stable) and cap the reorg fed to it to | |
| # its reliable range (beyond ~this, symmetric MHC ~ BV(alpha=0.5) and the BV limb | |
| # dominates the blend, so the cap is physically invisible). | |
| _REORG_MHC_MAX = 50.0 | |
| _MHC_DEG = 128 | |
| _MHC_PTS, _MHC_WTS = np.polynomial.hermite.hermgauss(_MHC_DEG) | |
| def _mhc_rates_smooth(Theta, K0, reorg): | |
| """Symmetric MHC (K_red, K_ox) via the legacy integrand at high, clipped | |
| Gauss-Hermite degree. Matches calc_mhc_rates but smooth and overflow-safe.""" | |
| s = np.sqrt(reorg) | |
| base = reorg * (_MHC_PTS * 2.0 / s - 1.0) # = 2*sqrt(reorg)*x - reorg | |
| def I_red(theta): | |
| arg = np.clip(-(base - theta), -500.0, 500.0) | |
| return np.sum(_MHC_WTS * (2.0 * s / (1.0 + np.exp(arg)))) | |
| def I_ox(theta): | |
| arg = np.clip(-base - theta, -500.0, 500.0) | |
| return np.sum(_MHC_WTS * (-2.0 * s / (1.0 + np.exp(arg)))) | |
| return K0 * I_red(Theta) / I_red(0.0), K0 * I_ox(Theta) / I_ox(0.0) | |
| def et_rates_unified(Theta, K0, log10_reorg, asym): | |
| """Unified (K_red, K_ox) electron-transfer rates: smooth blend of Butler- | |
| Volmer (transfer coefficient = asym) and symmetric Marcus-Hush-Chidsey, | |
| weighted by reorg so BV is the large-lambda limit and MHC the finite-lambda | |
| one. Used for both ET1 and ET2 so the ET sub-type axis is continuous.""" | |
| w = _bv_weight(log10_reorg) | |
| if w > 1.0 - _W_EPS: # pure BV limit (fast path) | |
| alpha = float(asym); beta = 1.0 - alpha | |
| K_red = K0 * np.exp(np.clip(-alpha * Theta, -500, 500)) | |
| K_ox = K0 * np.exp(np.clip(beta * Theta, -500, 500)) | |
| return _clamp_bv_rates(K_red, K_ox) | |
| reorg = min(10.0 ** log10_reorg, _REORG_MHC_MAX) | |
| if w < _W_EPS: # pure symmetric MHC (fast path) | |
| return _mhc_rates_smooth(Theta, K0, reorg) | |
| # blended regime | |
| alpha = float(asym); beta = 1.0 - alpha | |
| K_red_bv = K0 * np.exp(np.clip(-alpha * Theta, -500, 500)) | |
| K_ox_bv = K0 * np.exp(np.clip(beta * Theta, -500, 500)) | |
| K_red_bv, K_ox_bv = _clamp_bv_rates(K_red_bv, K_ox_bv) | |
| K_red_mhc, K_ox_mhc = _mhc_rates_smooth(Theta, K0, reorg) | |
| return (w * K_red_bv + (1.0 - w) * K_red_mhc, | |
| w * K_ox_bv + (1.0 - w) * K_ox_mhc) | |
| def et1_rates(Theta, log10_K0, log10_reorg, asym): | |
| """ET1 rate (K_red, K_ox) and an 'is_reversible' flag via the unified law. | |
| Returns (K_red, K_ox, reversible). When reversible (K0 large), the caller | |
| uses the Nernst boundary condition instead of a Robin (kinetic) surface row. | |
| """ | |
| if log10_K0 >= REVERSIBLE_K0 - 0.5: | |
| return None, None, True | |
| K_red, K_ox = et_rates_unified(Theta, 10.0 ** log10_K0, log10_reorg, asym) | |
| return K_red, K_ox, False | |
| def _run_ee_3species(theta_super, sigma=1.0, C_A_bulk=1.0, C_B_bulk=0.0, | |
| C_C_bulk=0.0, theta_i=20.0, theta_v=-20.0, cycles=1, | |
| irreversible_et2=False): | |
| """EE (two sequential electron transfers) with a tracked 3rd species C and | |
| a real, reversible 2nd electron transfer B + e- <-> C. | |
| State vector length 3n: A in [0:n], B in [n:2n], C in [2n:3n]. Surface at | |
| A[n-1], B[n], C[2n]; far bulk at A[0], B[2n-1], C[3n-1]. | |
| irreversible_et2=True zeros K_ox_2 (no reverse 2nd ET); in that limit C is a | |
| passive bystander and A/B/flux match the existing run_ee_simulation exactly. | |
| """ | |
| p = _unpack(theta_super) | |
| K0_1 = 10.0 ** p["log10_K0_1"]; alpha_1 = float(p["asym_1"]); lam1 = float(p["log10_reorg_e_1"]) | |
| K0_2 = 10.0 ** p["log10_K0_2"]; alpha_2 = float(p["asym_2"]); lam2 = float(p["log10_reorg_e_2"]) | |
| E0_2 = float(p["E0_2_offset"]) | |
| dA = 10.0 ** p["log10_dA"]; dB = 10.0 ** p["log10_dB"]; dC = 10.0 ** p["log10_dC"] | |
| deltaT = DELTA_THETA / sigma | |
| maxT = cycles * 2.0 * abs(theta_v - theta_i) / sigma | |
| E = _make_potential_waveform(theta_i, theta_v, cycles) | |
| total_steps = len(E) | |
| X_grid, n = gen_grid(0.0, DELTA_X, SIMULATION_SPACE_MULTIPLE * np.sqrt(maxT), | |
| EXPANDING_GRID_FACTOR) | |
| conc = np.zeros(3 * n) | |
| conc[0:n] = C_A_bulk; conc[n:2 * n] = C_B_bulk; conc[2 * n:3 * n] = C_C_bulk | |
| conc_d = conc.copy() | |
| z = np.zeros(n) | |
| aA, bA, cA = calc_abc_linear(n, X_grid, deltaT, z.copy(), z.copy(), z.copy(), dA) | |
| aB, bB, cB = calc_abc_linear(n, X_grid, deltaT, z.copy(), z.copy(), z.copy(), dB) | |
| aC, bC, cC = calc_abc_linear(n, X_grid, deltaT, z.copy(), z.copy(), z.copy(), dC) | |
| fluxes = np.zeros(total_steps) | |
| X0 = X_grid[1] - X_grid[0] | |
| # Constant (rate-independent) entries assembled ONCE: interior diffusion bands, | |
| # far-field Dirichlet, and the -1 surface-neighbour links. Only the 7 surface | |
| # rate entries change per step (built sparse -> O(nnz) per step, not O(n^3)). | |
| rA = np.arange(n - 2, 0, -1); rB = np.arange(n + 1, 2 * n - 1) | |
| rC = np.arange(2 * n + 1, 3 * n - 1) | |
| rows_c = np.concatenate([rA, rA, rA, rB, rB, rB, rC, rC, rC, | |
| [0, 2 * n - 1, 3 * n - 1, n - 1, n, 2 * n]]) | |
| cols_c = np.concatenate([rA - 1, rA, rA + 1, rB - 1, rB, rB + 1, rC - 1, rC, rC + 1, | |
| [0, 2 * n - 1, 3 * n - 1, n - 2, n + 1, 2 * n + 1]]) | |
| vals_c = np.concatenate([cA[1:n - 1], bA[1:n - 1], aA[1:n - 1], | |
| aB[1:n - 1], bB[1:n - 1], cB[1:n - 1], | |
| aC[1:n - 1], bC[1:n - 1], cC[1:n - 1], | |
| [1.0, 1.0, 1.0, -1.0, -1.0, -1.0]]).astype(np.float64) | |
| s_rows = np.array([n - 1, n - 1, n, n, n, 2 * n, 2 * n]) | |
| s_cols = np.array([n - 1, n, n - 1, n, 2 * n, n, 2 * n]) | |
| all_rows = np.concatenate([rows_c, s_rows]); all_cols = np.concatenate([cols_c, s_cols]) | |
| for idx in range(total_steps): | |
| Theta = E[idx]; Theta2 = Theta - E0_2 | |
| K_red_1, K_ox_1 = et_rates_unified(Theta, K0_1, lam1, alpha_1) | |
| K_red_2, K_ox_2 = et_rates_unified(Theta2, K0_2, lam2, alpha_2) | |
| if irreversible_et2: | |
| K_ox_2 = 0.0 | |
| s_vals = np.array([1.0 + X0 / dA * K_red_1, -X0 / dA * K_ox_1, | |
| -X0 / dB * K_red_1, 1.0 + X0 / dB * (K_ox_1 + K_red_2), | |
| -X0 / dB * K_ox_2, | |
| -X0 / dC * K_red_2, 1.0 + X0 / dC * K_ox_2]) | |
| A_matrix = csr_matrix((np.concatenate([vals_c, s_vals]), (all_rows, all_cols)), | |
| shape=(3 * n, 3 * n)) | |
| conc_d[:] = conc[:] | |
| conc_d[n - 1] = 0.0; conc_d[n] = 0.0; conc_d[2 * n] = 0.0 | |
| conc_d[0] = C_A_bulk; conc_d[2 * n - 1] = C_B_bulk; conc_d[3 * n - 1] = C_C_bulk | |
| conc = spsolve(A_matrix, conc_d) | |
| flux_1 = calc_flux(conc, n, dA, X_grid) | |
| flux_2 = K_red_2 * conc[n] - K_ox_2 * conc[2 * n] | |
| fluxes[idx] = flux_1 + flux_2 | |
| return {"potential": E, "flux": fluxes, "n": n} | |
| def _run_ece_3species(theta_super, sigma=1.0, C_A_bulk=1.0, C_B_bulk=0.0, | |
| C_C_bulk=0.0, theta_i=20.0, theta_v=-20.0, cycles=1): | |
| """True ECE: ET1 (A + e- <-> B), chemical B -> C (rate kc), ET2 (C + e- <-> D). | |
| 3-species state (A, B, C); D untracked (irreversible 2nd ET product, D=0). | |
| ET1 acts on A/B at the surface; the chemical step B->C is operator-split in | |
| the bulk (same physical depth, B and C share orientation); ET2 consumes C at | |
| the surface. flux = flux_1(ET1) + flux_2(ET2 on C). | |
| (The existing run_ece_simulation is EC-with-decay - its 2nd ET is dead code - | |
| so this is a corrected mechanism, validated by physics not by matching it.) | |
| """ | |
| p = _unpack(theta_super) | |
| K0_1 = 10.0 ** p["log10_K0_1"]; alpha_1 = float(p["asym_1"]); lam1 = float(p["log10_reorg_e_1"]) | |
| K0_2 = 10.0 ** p["log10_K0_2"]; alpha_2 = float(p["asym_2"]); lam2 = float(p["log10_reorg_e_2"]) | |
| E0_2 = float(p["E0_2_offset"]) | |
| kc = 10.0 ** p["log10_kc"] | |
| dA = 10.0 ** p["log10_dA"]; dB = 10.0 ** p["log10_dB"]; dC = 10.0 ** p["log10_dC"] | |
| deltaT = DELTA_THETA / sigma | |
| decay_kc = np.exp(-kc * deltaT) | |
| maxT = cycles * 2.0 * abs(theta_v - theta_i) / sigma | |
| E = _make_potential_waveform(theta_i, theta_v, cycles) | |
| total_steps = len(E) | |
| X_grid, n = gen_grid(0.0, DELTA_X, SIMULATION_SPACE_MULTIPLE * np.sqrt(maxT), | |
| EXPANDING_GRID_FACTOR) | |
| conc = np.zeros(3 * n) | |
| conc[0:n] = C_A_bulk; conc[n:2 * n] = C_B_bulk; conc[2 * n:3 * n] = C_C_bulk | |
| conc_d = conc.copy() | |
| z = np.zeros(n) | |
| aA, bA, cA = calc_abc_linear(n, X_grid, deltaT, z.copy(), z.copy(), z.copy(), dA) | |
| aB, bB, cB = calc_abc_linear(n, X_grid, deltaT, z.copy(), z.copy(), z.copy(), dB) | |
| aC, bC, cC = calc_abc_linear(n, X_grid, deltaT, z.copy(), z.copy(), z.copy(), dC) | |
| fluxes = np.zeros(total_steps) | |
| X0 = X_grid[1] - X_grid[0] | |
| # Constant entries assembled once (see _run_ee_3species). ECE surface couples | |
| # ET1 on A/B and ET2 consuming C; 5 rate entries change per step. | |
| rA = np.arange(n - 2, 0, -1); rB = np.arange(n + 1, 2 * n - 1) | |
| rC = np.arange(2 * n + 1, 3 * n - 1) | |
| rows_c = np.concatenate([rA, rA, rA, rB, rB, rB, rC, rC, rC, | |
| [0, 2 * n - 1, 3 * n - 1, n - 1, n, 2 * n]]) | |
| cols_c = np.concatenate([rA - 1, rA, rA + 1, rB - 1, rB, rB + 1, rC - 1, rC, rC + 1, | |
| [0, 2 * n - 1, 3 * n - 1, n - 2, n + 1, 2 * n + 1]]) | |
| vals_c = np.concatenate([cA[1:n - 1], bA[1:n - 1], aA[1:n - 1], | |
| aB[1:n - 1], bB[1:n - 1], cB[1:n - 1], | |
| aC[1:n - 1], bC[1:n - 1], cC[1:n - 1], | |
| [1.0, 1.0, 1.0, -1.0, -1.0, -1.0]]).astype(np.float64) | |
| s_rows = np.array([n - 1, n - 1, n, n, 2 * n]) | |
| s_cols = np.array([n - 1, n, n - 1, n, 2 * n]) | |
| all_rows = np.concatenate([rows_c, s_rows]); all_cols = np.concatenate([cols_c, s_cols]) | |
| for idx in range(total_steps): | |
| Theta = E[idx]; Theta2 = Theta - E0_2 | |
| K_red_1, K_ox_1 = et_rates_unified(Theta, K0_1, lam1, alpha_1) | |
| K_red_2, K_ox_2 = et_rates_unified(Theta2, K0_2, lam2, alpha_2) | |
| s_vals = np.array([1.0 + X0 / dA * K_red_1, -X0 / dA * K_ox_1, | |
| -X0 / dB * K_red_1, 1.0 + X0 / dB * K_ox_1, | |
| 1.0 + X0 / dC * K_red_2]) | |
| A_matrix = csr_matrix((np.concatenate([vals_c, s_vals]), (all_rows, all_cols)), | |
| shape=(3 * n, 3 * n)) | |
| conc_d[:] = conc[:] | |
| conc_d[n - 1] = 0.0; conc_d[n] = 0.0; conc_d[2 * n] = 0.0 | |
| conc_d[0] = C_A_bulk; conc_d[2 * n - 1] = C_B_bulk; conc_d[3 * n - 1] = C_C_bulk | |
| conc = spsolve(A_matrix, conc_d) | |
| flux_1 = calc_flux(conc, n, dA, X_grid) | |
| flux_2 = K_red_2 * conc[2 * n] | |
| fluxes[idx] = flux_1 + flux_2 | |
| # operator-split chemical B -> C (same physical depth; shared orientation) | |
| removed = conc[n:2 * n - 1] * (1.0 - decay_kc) | |
| conc[n:2 * n - 1] -= removed | |
| conc[2 * n:3 * n - 1] += removed | |
| conc[2 * n - 1] = C_B_bulk | |
| return {"potential": E, "flux": fluxes, "n": n} | |
| def run_cv_master(theta_super, sigma=1.0, C_A_bulk=None, C_B_bulk=0.0, | |
| C_Y_bulk=1.0, theta_i=20.0, theta_v=-20.0, cycles=1): | |
| """Master CV simulation (M1a ET1 + M1b chemical steps). | |
| Active chemical steps (from theta_super log-rates above the off-floor): | |
| - following chemical B->Y (log10_kc) : conc[n:2n-1] *= exp(-kc*dt) | |
| - catalytic regeneration B->A (log10_kcat): operator-split B->A transfer | |
| - preceding equilibrium Y<->A (log10_kf, log10_Keq): source toward C_A_eq, | |
| with bulk/initial C_A = Keq*C_Y/(1+Keq). | |
| """ | |
| p = _unpack(theta_super) | |
| # ET2 active -> 3-species path. With a chemical step it is ECE; without, EE. | |
| if p["log10_K0_2"] > _ON: | |
| ca = 1.0 if C_A_bulk is None else C_A_bulk | |
| if p["log10_kc"] > _ON: | |
| return _run_ece_3species( | |
| theta_super, sigma=sigma, C_A_bulk=ca, C_B_bulk=C_B_bulk, | |
| theta_i=theta_i, theta_v=theta_v, cycles=cycles) | |
| return _run_ee_3species( | |
| theta_super, sigma=sigma, C_A_bulk=ca, C_B_bulk=C_B_bulk, | |
| theta_i=theta_i, theta_v=theta_v, cycles=cycles) | |
| dA = 10.0 ** p["log10_dA"] | |
| dB = 10.0 ** p["log10_dB"] | |
| # chemical-step activity + rates | |
| kc_on = p["log10_kc"] > _ON | |
| kcat_on = p["log10_kcat"] > _ON | |
| pre_on = p["log10_kf"] > _ON | |
| kc = 10.0 ** p["log10_kc"] if kc_on else 0.0 | |
| kcat = 10.0 ** p["log10_kcat"] if kcat_on else 0.0 | |
| kf = 10.0 ** p["log10_kf"] if pre_on else 0.0 | |
| Keq = 10.0 ** p["log10_Keq"] | |
| C_A_eq = Keq * C_Y_bulk / (1.0 + Keq) if pre_on else None | |
| if C_A_bulk is None: | |
| C_A_bulk = C_A_eq if pre_on else 1.0 | |
| deltaT = DELTA_THETA / sigma | |
| decay_kc = np.exp(-kc * deltaT) | |
| decay_kcat = np.exp(-kcat * deltaT) | |
| maxT = cycles * 2.0 * abs(theta_v - theta_i) / sigma | |
| nTimeSteps = int(2 * abs(theta_v - theta_i) / DELTA_THETA) + 1 | |
| Esteps = np.arange(nTimeSteps) | |
| E = np.where(Esteps < nTimeSteps / 2.0, | |
| theta_i - DELTA_THETA * Esteps, | |
| theta_v + DELTA_THETA * (Esteps - nTimeSteps / 2.0)) | |
| E = np.tile(E, cycles) | |
| total_steps = len(E) | |
| maxX = SIMULATION_SPACE_MULTIPLE * np.sqrt(maxT) | |
| X_grid, n = gen_grid(0.0, DELTA_X, maxX, EXPANDING_GRID_FACTOR) | |
| conc, conc_d = ini_conc(n, C_A_bulk, C_B_bulk) | |
| A_matrix, aA, bA, cA, aB, bB, cB = ini_coeff(n) | |
| aA, bA, cA = calc_abc_linear(n, X_grid, deltaT, aA, bA, cA, dA) | |
| aB, bB, cB = calc_abc_linear(n, X_grid, deltaT, aB, bB, cB, dB) | |
| X0 = X_grid[1] - X_grid[0] | |
| fluxes = np.zeros(total_steps) | |
| for idx in range(total_steps): | |
| Theta = E[idx] | |
| K_red, K_ox, reversible = et1_rates( | |
| Theta, p["log10_K0_1"], p["log10_reorg_e_1"], p["asym_1"]) | |
| # --- assemble matrix (interior diffusion bands, reused layout) --- | |
| A_matrix[:] = 0.0 | |
| rows_A = np.arange(n - 2, 0, -1) | |
| A_matrix[rows_A, rows_A - 1] = cA[1:n - 1] | |
| A_matrix[rows_A, rows_A] = bA[1:n - 1] | |
| A_matrix[rows_A, rows_A + 1] = aA[1:n - 1] | |
| rows_B = np.arange(n + 1, 2 * n - 1) | |
| A_matrix[rows_B, rows_B - 1] = aB[1:n - 1] | |
| A_matrix[rows_B, rows_B] = bB[1:n - 1] | |
| A_matrix[rows_B, rows_B + 1] = cB[1:n - 1] | |
| # --- bulk far-field Dirichlet rows --- | |
| A_matrix[0, 0] = 1.0 | |
| A_matrix[0, 1] = 0.0 | |
| A_matrix[2 * n - 1, 2 * n - 1] = 1.0 | |
| A_matrix[2 * n - 1, 2 * n - 2] = 0.0 | |
| # --- surface boundary rows --- | |
| if reversible: # Nernst | |
| A_matrix[n - 1, n - 1] = 1.0 | |
| A_matrix[n, n - 2] = -dA | |
| A_matrix[n, n - 1] = dA | |
| A_matrix[n, n] = dB | |
| A_matrix[n, n + 1] = -dB | |
| else: # Robin (BV / MHC) | |
| A_matrix[n - 1, n - 2] = -1.0 | |
| A_matrix[n - 1, n - 1] = 1.0 + X0 / dA * K_red | |
| A_matrix[n - 1, n] = -X0 / dA * K_ox | |
| A_matrix[n, n - 1] = -X0 / dB * K_red | |
| A_matrix[n, n] = 1.0 + X0 / dB * K_ox | |
| A_matrix[n, n + 1] = -1.0 | |
| # --- RHS --- | |
| conc_d[:] = conc[:] | |
| if reversible: | |
| conc_d[n - 1] = 1.0 / (1.0 + np.exp(-Theta)) | |
| conc_d[n] = 0.0 | |
| else: | |
| conc_d[n - 1] = 0.0 | |
| conc_d[n] = 0.0 | |
| conc_d[0] = C_A_bulk | |
| conc_d[2 * n - 1] = C_B_bulk | |
| conc = scipy.linalg.solve(A_matrix, conc_d) | |
| fluxes[idx] = calc_flux(conc, n, dA, X_grid) | |
| # --- operator-split chemical steps (after flux, before next step) --- | |
| if kc_on: # following chemical B -> Y | |
| conc[n:2 * n - 1] *= decay_kc | |
| conc[2 * n - 1] = C_B_bulk | |
| if kcat_on: # catalytic regeneration B -> A | |
| b_interior = conc[n + 2:2 * n - 1].copy() | |
| amount = b_interior * (1.0 - decay_kcat) | |
| conc[n + 2:2 * n - 1] -= amount | |
| conc[1:n - 2] += amount[::-1] | |
| if pre_on: # preceding equilibrium Y <-> A | |
| for i in range(2, n - 1): | |
| c_a = conc[n - 1 - i] | |
| conc[n - 1 - i] = max(c_a + kf * (C_A_eq - c_a) * deltaT, 0.0) | |
| return {"potential": E, "flux": fluxes, "n": n} | |