driftlet 1.3.2 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/device.md +79 -6
- package/docs/internals.md +5 -2
- package/docs/kit.md +7 -3
- package/docs/numerics.md +11 -0
- package/docs/statistics.md +7 -2
- package/docs/templates.md +157 -2
- package/llms.txt +19 -14
- package/package.json +1 -1
- package/src/boundaries.js +200 -14
- package/src/checks.js +13 -2
- package/src/describe.js +3 -1
- package/src/device.js +70 -5
- package/src/diffuse.js +240 -0
- package/src/impedance.js +2 -1
- package/src/kit.js +6 -2
- package/src/lawRows.js +26 -1
- package/src/newton.js +31 -5
- package/src/regions.js +6 -3
- package/src/solution.js +46 -6
- package/src/solver.js +87 -9
- package/src/start.js +37 -8
- package/src/statistics.js +1 -0
- package/src/steady.js +51 -22
- package/src/timeSteps.js +10 -6
- package/src/types.js +16 -2
- package/types/boundaries.d.ts +8 -0
- package/types/diffuse.d.ts +25 -0
- package/types/newton.d.ts +1 -1
- package/types/solver.d.ts +8 -2
- package/types/types.d.ts +36 -1
package/src/diffuse.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// A diffuse (Gouy–Chapman) layer treated analytically: the ions' Boltzmann layer beside a face,
|
|
2
|
+
// between the face's plane of closest approach and a strictly neutral bulk, held as one number
|
|
3
|
+
// per face side instead of on the grid.
|
|
4
|
+
//
|
|
5
|
+
// CONTRACT
|
|
6
|
+
//
|
|
7
|
+
// diffuseLayer(c, z, n, s, kappaUnits, out, lattice, draw) is a pure function of its arguments.
|
|
8
|
+
// c (mol/m³): the bulk concentrations beside the layer, n of them (species absent there at 0);
|
|
9
|
+
// z: their charges; s = F ψ/RT, the diffuse drop ψ = φ(plane) − φ(bulk) in thermal units;
|
|
10
|
+
// kappaUnits = { A: √(ε RT/2)/F (mol/m² per √(mol/m³)), B: √(2 ε RT) (C/m² per √(mol/m³)) },
|
|
11
|
+
// ε the layer's permittivity (F/m); lattice null (dilute ions) or { cMax, on }, the bulk's lattice
|
|
12
|
+
// statistics (src/statistics.js), on[i] = 1 for the species on its sites (every charged one);
|
|
13
|
+
// draw null, or draw[i] = 1 for the species whose transport across the layer is wanted (K below).
|
|
14
|
+
// Dilute, c_i(s) = c_i e^{−z_i s}; crowded (Bikerman: the same sites as the bulk's lattice), divided
|
|
15
|
+
// by D(s) = 1 + Σ_on θ_j (e^{−z_j s} − 1), θ_j = c_j/cMax, and the first integral is then
|
|
16
|
+
// G(s) = cMax ln D + s Σ c_j z_j, its neutral species displaced from the plane (an excess < 0).
|
|
17
|
+
// It writes into out (a scratch object it fills: { q, dqds, dqdc[n], gam[n], dgds[n], dgdc[n×n],
|
|
18
|
+
// K[n], dKds[n], dKdc[n×n], D, dDds, dDdc[n] }: D(s) and its derivatives, 1 and 0 for a dilute layer):
|
|
19
|
+
// q the layer's charge per area (C/m²), −B s √R(s), R = G/t²; dilute, R(t) = Σ_j c_j z_j² ρ(z_j t),
|
|
20
|
+
// ρ(x) = (e^{−x} − 1 + x)/x², so R(t) t² = Σ c_j (e^{−z_j t} − 1 + z_j t), the first
|
|
21
|
+
// integral of Poisson–Boltzmann (exact where the bulk is neutral, F Σ z c + ρ_fixed = 0,
|
|
22
|
+
// with any fixed charge uniform through the layer as in the bulk; written with its linear
|
|
23
|
+
// term so it stays ≥ 0 off neutrality, as Newton's iterates are);
|
|
24
|
+
// gam each species' surface excess ∫ (c_i(x) − c_i) dx (mol/m²), c_i A ∫₀ˢ E_i(t)/√R(t) dt,
|
|
25
|
+
// E_i(t) = (c_i(t)/c_i − 1)/t: smooth through s = 0, where everything vanishes (crowded,
|
|
26
|
+
// written so that no two large terms cancel, past |X| = 1 by G whole);
|
|
27
|
+
// K for each drawn species, the layer's resistance to it in excess of the bulk's it displaces,
|
|
28
|
+
// as a length (m): K_i = ∫ (c_i/c_i(x) − 1) dx = A ∫₀ˢ H_i(t)/√R(t) dt, H_i = (c_i/c_i(t) − 1)/t.
|
|
29
|
+
// A flux N_i toward the plane (mol/(m²·s)), through the layer's profile, lowers its level
|
|
30
|
+
// there: a_plane = a_edge (1 − N_i K_i/(D_i c_i)), exact for a species too dilute to shape the
|
|
31
|
+
// layer (the reactant a charged electrode repels, the case that matters: K_i grows as
|
|
32
|
+
// c_i/c_i(plane)), first order in N otherwise; < 0 for one the layer gathers;
|
|
33
|
+
// and their derivatives: dqds = ∂q/∂s, dqdc[j] = ∂q/∂c_j, dgds[i] = ∂Γ_i/∂s,
|
|
34
|
+
// dgdc[i·n + j] = ∂Γ_i/∂c_j, dKds[i] = ∂K_i/∂s, dKdc[i·n + j] = ∂K_i/∂c_j (0 where not drawn).
|
|
35
|
+
// Invariants (each says what a reviewer should be able to check here):
|
|
36
|
+
// 1. F Σ z_i Γ_i = q at a neutral bulk (Gauss's law across the layer), to the quadrature's
|
|
37
|
+
// accuracy (≤ 1e-13 relative).
|
|
38
|
+
// 2. q, Γ_i are odd in (s, z) together: flipping every charge and s gives −q and the same Γ.
|
|
39
|
+
// 3. The derivatives are those of the functions (the quadrature's error aside): ∂/∂s exact by
|
|
40
|
+
// the fundamental theorem, ∂/∂c_j by the same rule as Γ (and K).
|
|
41
|
+
// 4. Against the Poisson–Boltzmann problem solved in x (scipy solve_bvp), and a z:z salt's
|
|
42
|
+
// closed forms q = −√(8εRTc) sinh(zs/2), Γ_± = (2c/κ)(e^{∓zs/2} − 1): within 1e-9 and 1e-14
|
|
43
|
+
// (test/diffuse.test.js, the tables from the answer sheets); crowded, against the same
|
|
44
|
+
// problem with the lattice's concentrations, and Kilic–Bazant–Ajdari's q for a z:z salt;
|
|
45
|
+
// K, against ∫ (e^{z φ̃(x)} − 1) dx on that problem's profile, and a z:z salt's closed form
|
|
46
|
+
// (2/κ)(e^{±zs/2} − 1) (its own Γ with the charge flipped: c/c(x) = e^{+z φ̃}).
|
|
47
|
+
// A dilute layer overstates the charge and the excesses once the plane's concentration passes a
|
|
48
|
+
// few molar; a lattice bounds it at cMax. Arguments past |z s| = 600 are held there (e^600 is
|
|
49
|
+
// the end of double precision), so absurd Newton iterates stay finite.
|
|
50
|
+
|
|
51
|
+
// Gauss–Legendre, 8 points on [0, 1].
|
|
52
|
+
const GL_X = [0.019855071751231856, 0.10166676129318664, 0.2372337950418355, 0.4082826787521751, 0.5917173212478249, 0.7627662049581645, 0.8983332387068134, 0.9801449282487681];
|
|
53
|
+
const GL_W = [0.05061426814518813, 0.11119051722668724, 0.15685332293894363, 0.18134189168918100, 0.18134189168918100, 0.15685332293894363, 0.11119051722668724, 0.05061426814518813];
|
|
54
|
+
const CAP = 600;
|
|
55
|
+
|
|
56
|
+
// ρ(x) = (e^{−x} − 1 + x)/x² (1/2 at 0), by its series where the difference would cancel.
|
|
57
|
+
function rho(x) {
|
|
58
|
+
if (x > CAP) x = CAP;
|
|
59
|
+
else if (x < -CAP) x = -CAP;
|
|
60
|
+
if (Math.abs(x) < 0.5) {
|
|
61
|
+
// Σ_{k≥0} (−x)^k/(k+2)!
|
|
62
|
+
let term = 0.5, sum = 0.5;
|
|
63
|
+
for (let k = 1; k < 18; k++) {
|
|
64
|
+
term *= -x / (k + 2);
|
|
65
|
+
sum += term;
|
|
66
|
+
}
|
|
67
|
+
return sum;
|
|
68
|
+
}
|
|
69
|
+
return (Math.expm1(-x) + x) / (x * x);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// e(x) = (1 − e^{−x})/x (1 at 0), so E_i(t) = −z_i e(z_i t).
|
|
73
|
+
function eps1(x) {
|
|
74
|
+
if (x > CAP) x = CAP;
|
|
75
|
+
else if (x < -CAP) x = -CAP;
|
|
76
|
+
return x === 0 ? 1 : -Math.expm1(-x) / x;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// λ(X) = (log1p(X) − X)/X² (−1/2 at 0), by its series where the difference would cancel.
|
|
80
|
+
function lam(X) {
|
|
81
|
+
if (Math.abs(X) < 0.1) {
|
|
82
|
+
// Σ_{k≥0} (−1)^{k+1} X^k/(k + 2)
|
|
83
|
+
let p = 1, sum = 0;
|
|
84
|
+
for (let k = 0; k < 24; k++, p *= -X) sum -= p / (k + 2);
|
|
85
|
+
return sum;
|
|
86
|
+
}
|
|
87
|
+
return (Math.log1p(X) - X) / (X * X);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The layer's functions at t (a scratch `at` to fill): R(t) = G(t)/t², each species' E_i(t) =
|
|
91
|
+
// (c_i(t)/c_i − 1)/t, and their ∂/∂c_j, R_j and (crowded) Ec_ij = ∂E_i/∂c_j; and D(t), its ∂/∂t and
|
|
92
|
+
// ∂/∂c_j. Dilute: c_i(t) = c_i e^{−z_i t}; on a lattice of cMax sites, divided by D(t) = 1 + X(t),
|
|
93
|
+
// X = Σ_lattice θ_j (e^{−z_j t} − 1), θ_j = c_j/cMax, and G = cMax ln D + t Σ c_j z_j (Bikerman).
|
|
94
|
+
// Drawn (draw[i]), H_i(t) = (c_i/c_i(t) − 1)/t too, the resistance's integrand, and (crowded)
|
|
95
|
+
// Hc_ij = ∂H_i/∂c_j.
|
|
96
|
+
function functionsAt(c, z, n, t, lat, at, draw) {
|
|
97
|
+
const { Rj, E, Ec, dDdc, H, Hc } = at;
|
|
98
|
+
let R = 0;
|
|
99
|
+
for (let j = 0; j < n; j++) {
|
|
100
|
+
Rj[j] = z[j] !== 0 ? z[j] * z[j] * rho(z[j] * t) : 0;
|
|
101
|
+
if (c[j] > 0) R += c[j] * Rj[j];
|
|
102
|
+
E[j] = z[j] !== 0 ? -z[j] * eps1(z[j] * t) : 0;
|
|
103
|
+
if (draw) H[j] = draw[j] && z[j] !== 0 ? z[j] * eps1(-z[j] * t) : 0; // (e^{z t} − 1)/t
|
|
104
|
+
}
|
|
105
|
+
at.D = 1;
|
|
106
|
+
at.dDdt = 0;
|
|
107
|
+
if (!lat) {
|
|
108
|
+
at.R = R;
|
|
109
|
+
return at;
|
|
110
|
+
}
|
|
111
|
+
// Y = X/t, smooth through 0; D' = −Σ θ z e^{−z t}
|
|
112
|
+
const { cMax, on } = lat;
|
|
113
|
+
let Y = 0, dD = 0;
|
|
114
|
+
for (let j = 0; j < n; j++) {
|
|
115
|
+
dDdc[j] = 0;
|
|
116
|
+
if (!on[j] || z[j] === 0) continue;
|
|
117
|
+
const x = Math.max(-CAP, Math.min(CAP, -z[j] * t));
|
|
118
|
+
Y -= ((c[j] > 0 ? c[j] : 0) / cMax) * z[j] * eps1(z[j] * t);
|
|
119
|
+
dD -= ((c[j] > 0 ? c[j] : 0) / cMax) * z[j] * Math.exp(x);
|
|
120
|
+
dDdc[j] = Math.expm1(x) / cMax;
|
|
121
|
+
}
|
|
122
|
+
const X = Y * t, D = 1 + X;
|
|
123
|
+
at.D = D;
|
|
124
|
+
at.dDdt = dD;
|
|
125
|
+
if (Math.abs(X) <= 1) {
|
|
126
|
+
// the dilute layer's R and a correction, cMax (log1p X − X)/t² = cMax Y² λ(X); its ∂/∂c_j, Y z_j e(z_j t)/D
|
|
127
|
+
at.R = R + cMax * Y * Y * lam(X);
|
|
128
|
+
for (let j = 0; j < n; j++) if (on[j] && z[j] !== 0) Rj[j] += (Y * z[j] * eps1(z[j] * t)) / D;
|
|
129
|
+
} else {
|
|
130
|
+
// (far out, where the two would cancel: G whole, and ∂G/∂c_j = expm1(−z_j t)/D + z_j t)
|
|
131
|
+
let Gl = cMax * Math.log1p(X);
|
|
132
|
+
for (let j = 0; j < n; j++) if (on[j] && z[j] !== 0 && c[j] > 0) Gl += c[j] * z[j] * t;
|
|
133
|
+
at.R = Gl / (t * t);
|
|
134
|
+
for (let j = 0; j < n; j++) if (on[j] && z[j] !== 0) Rj[j] = (Math.expm1(Math.max(-CAP, Math.min(CAP, -z[j] * t))) / D + z[j] * t) / (t * t);
|
|
135
|
+
}
|
|
136
|
+
// E_i = (e^{−z_i t}/D − 1)/t = (−z_i e(z_i t) − Y)/D, a lattice's neutral species too; ∂E_i/∂c_j = e^{−z_i t} z_j e(z_j t)/(cMax D²)
|
|
137
|
+
for (let i = 0; i < n; i++) {
|
|
138
|
+
if (!on[i]) continue;
|
|
139
|
+
E[i] = (E[i] - Y) / D;
|
|
140
|
+
const w = Math.exp(Math.max(-CAP, Math.min(CAP, -z[i] * t))) / (cMax * D * D);
|
|
141
|
+
for (let j = 0; j < n; j++) Ec[i * n + j] = on[j] && z[j] !== 0 ? w * z[j] * eps1(z[j] * t) : 0;
|
|
142
|
+
// H_i = (e^{z_i t} D − 1)/t = z_i e(−z_i t) + e^{z_i t} Y; ∂H_i/∂c_j = −e^{z_i t} z_j e(z_j t)/cMax
|
|
143
|
+
if (!draw?.[i]) continue;
|
|
144
|
+
const up = Math.exp(Math.max(-CAP, Math.min(CAP, z[i] * t)));
|
|
145
|
+
H[i] += up * Y;
|
|
146
|
+
for (let j = 0; j < n; j++) Hc[i * n + j] = on[j] && z[j] !== 0 ? (-up * z[j] * eps1(z[j] * t)) / cMax : 0;
|
|
147
|
+
}
|
|
148
|
+
return at;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The diffuse layer's charge, excesses and their derivatives (see the contract above). */
|
|
152
|
+
export function diffuseLayer(c, z, n, s, { A, B }, out, lat = null, draw = null) {
|
|
153
|
+
const { dqdc, gam, dgds, dgdc, K, dKds, dKdc } = out;
|
|
154
|
+
let zmax = 0;
|
|
155
|
+
for (let i = 0; i < n; i++) if (c[i] > 0) zmax = Math.max(zmax, Math.abs(z[i]));
|
|
156
|
+
gam.fill(0);
|
|
157
|
+
dgds.fill(0);
|
|
158
|
+
dgdc.fill(0);
|
|
159
|
+
dqdc.fill(0);
|
|
160
|
+
K.fill(0);
|
|
161
|
+
dKds.fill(0);
|
|
162
|
+
dKdc.fill(0);
|
|
163
|
+
out.D = 1;
|
|
164
|
+
out.dDds = 0;
|
|
165
|
+
out.dDdc.fill(0);
|
|
166
|
+
if (zmax === 0) {
|
|
167
|
+
out.q = 0;
|
|
168
|
+
out.dqds = 0;
|
|
169
|
+
return out;
|
|
170
|
+
}
|
|
171
|
+
const holds = (i) => z[i] !== 0 || (lat && lat.on[i]); // (a lattice's neutral species is displaced)
|
|
172
|
+
const at = out.at ?? (out.at = { Rj: new Float64Array(n), E: new Float64Array(n), Ec: new Float64Array(n * n), dDdc: new Float64Array(n), H: new Float64Array(n), Hc: new Float64Array(n * n) });
|
|
173
|
+
if (draw) at.Hc.fill(0);
|
|
174
|
+
// At s itself: the charge and the ∂/∂s.
|
|
175
|
+
functionsAt(c, z, n, s, lat, at, draw);
|
|
176
|
+
const sqR = Math.sqrt(at.R);
|
|
177
|
+
out.q = -B * s * sqR;
|
|
178
|
+
out.D = at.D;
|
|
179
|
+
out.dDds = at.dDdt;
|
|
180
|
+
out.dDdc.set(at.dDdc);
|
|
181
|
+
let dq = 0;
|
|
182
|
+
for (let i = 0; i < n; i++) {
|
|
183
|
+
if (!(c[i] > 0) || !holds(i)) continue;
|
|
184
|
+
dgds[i] = (c[i] * A * at.E[i]) / sqR;
|
|
185
|
+
dq += c[i] * z[i] * at.E[i];
|
|
186
|
+
}
|
|
187
|
+
if (draw) for (let i = 0; i < n; i++) if (draw[i]) dKds[i] = (A * at.H[i]) / sqR;
|
|
188
|
+
out.dqds = (B * dq) / (2 * sqR);
|
|
189
|
+
for (let j = 0; j < n; j++) dqdc[j] = (-B * s * at.Rj[j]) / (2 * sqR);
|
|
190
|
+
if (s === 0) return out;
|
|
191
|
+
// ∫₀ˢ by panels a unit of z_max·t wide (each spans at most an e-fold of the integrand), 8 points each.
|
|
192
|
+
const panels = Math.max(1, Math.ceil(Math.min(Math.abs(s), CAP) * zmax)), h = s / panels;
|
|
193
|
+
const I = out.I ?? (out.I = new Float64Array(n));
|
|
194
|
+
I.fill(0);
|
|
195
|
+
for (let p = 0; p < panels; p++) {
|
|
196
|
+
for (let k = 0; k < 8; k++) {
|
|
197
|
+
const t = (p + GL_X[k]) * h, w = GL_W[k] * h;
|
|
198
|
+
functionsAt(c, z, n, t, lat, at, draw);
|
|
199
|
+
const r12 = 1 / Math.sqrt(at.R), r32 = r12 / at.R;
|
|
200
|
+
if (draw) {
|
|
201
|
+
// K_i = A ∫ H_i R^{-1/2}; its ∂/∂c_j, A ∫ (Hc_ij R^{-1/2} − ½ H_i R_j R^{-3/2})
|
|
202
|
+
for (let i = 0; i < n; i++) {
|
|
203
|
+
if (!draw[i]) continue;
|
|
204
|
+
K[i] += w * at.H[i] * r12;
|
|
205
|
+
const f = -0.5 * w * at.H[i] * r32;
|
|
206
|
+
for (let j = 0; j < n; j++) dKdc[i * n + j] += f * at.Rj[j] + w * at.Hc[i * n + j] * r12;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (let i = 0; i < n; i++) {
|
|
210
|
+
if (!holds(i)) continue;
|
|
211
|
+
I[i] += w * at.E[i] * r12;
|
|
212
|
+
// ∂/∂c_j of c_i ∫ E_i R^{-1/2}: c_i ∫ (∂E_i/∂c_j R^{-1/2} − ½ E_i R_j R^{-3/2})
|
|
213
|
+
if (!(c[i] > 0)) continue;
|
|
214
|
+
const f = -0.5 * w * at.E[i] * r32 * c[i];
|
|
215
|
+
for (let j = 0; j < n; j++) dgdc[i * n + j] += f * at.Rj[j] + (lat && lat.on[i] ? w * c[i] * at.Ec[i * n + j] * r12 : 0);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (let i = 0; i < n; i++) {
|
|
220
|
+
if (!holds(i)) continue;
|
|
221
|
+
gam[i] = (c[i] > 0 ? c[i] : 0) * A * I[i];
|
|
222
|
+
for (let j = 0; j < n; j++) dgdc[i * n + j] *= A;
|
|
223
|
+
dgdc[i * n + i] += A * I[i];
|
|
224
|
+
}
|
|
225
|
+
if (draw) {
|
|
226
|
+
for (let i = 0; i < n; i++) K[i] *= A;
|
|
227
|
+
for (let k = 0; k < n * n; k++) dKdc[k] *= A;
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A scratch object for diffuseLayer over n species. */
|
|
233
|
+
export function diffuseScratch(n) {
|
|
234
|
+
return { q: 0, dqds: 0, dqdc: new Float64Array(n), gam: new Float64Array(n), dgds: new Float64Array(n), dgdc: new Float64Array(n * n), K: new Float64Array(n), dKds: new Float64Array(n), dKdc: new Float64Array(n * n), D: 1, dDds: 0, dDdc: new Float64Array(n) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The constants A = √(εRT/2)/F and B = √(2εRT) for a layer of permittivity eps (F/m) at RT (J/mol). */
|
|
238
|
+
export function diffuseUnits(eps, RT, F) {
|
|
239
|
+
return { A: Math.sqrt((eps * RT) / 2) / F, B: Math.sqrt(2 * eps * RT) };
|
|
240
|
+
}
|
package/src/impedance.js
CHANGED
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
// in J (the assembly here runs with this.steady false).
|
|
85
85
|
// 4. Deterministic: the same state, frequencies and options give the same Z, bit for bit.
|
|
86
86
|
// Also written, left as the last assembly at dt = ∞ left them: this.sourceTime (the present
|
|
87
|
-
// time), cOld, thOld, th0Old, gateOld (the present state's), contactDStart, portQStart, the
|
|
87
|
+
// time), cOld, thOld, th0Old, gamOld, gateOld (the present state's), contactDStart, portQStart, the
|
|
88
88
|
// residual, the Jacobian and the terminal terms, sysRest and the concentrations; this.lawForm
|
|
89
89
|
// (every circuit law by the change, until a step sets it afresh).
|
|
90
90
|
// Tests: test/impedance.test.js (Macdonald's blocking electrodes, a junction's depletion
|
|
@@ -272,6 +272,7 @@ export class SmallSignal {
|
|
|
272
272
|
this.cOld.set(this.c);
|
|
273
273
|
this.thOld.set(this.th);
|
|
274
274
|
this.th0Old.set(this.th0);
|
|
275
|
+
this.gamOld.set(this.gam);
|
|
275
276
|
this.gateList.forEach((gt, j) => (this.gateOld[j] = this.u[gt.o] + this.uLo[gt.o]));
|
|
276
277
|
this.assemble(Infinity);
|
|
277
278
|
this.contactDStart = { ...this.contactD };
|
package/src/kit.js
CHANGED
|
@@ -50,10 +50,14 @@ export function vacuumLevel(def, side, path = 'vacuum') {
|
|
|
50
50
|
if (!Number.isFinite(side.offset)) fail(`${path}.offset must be a finite number (V)`);
|
|
51
51
|
if (side.anchor === 'phi') return -side.offset;
|
|
52
52
|
const sp = (def.species ?? []).find((s) => s.name === side.anchor);
|
|
53
|
-
if (!sp)
|
|
53
|
+
if (!sp)
|
|
54
|
+
fail(
|
|
55
|
+
`${path}.anchor must be 'phi' or a charged species of the device, got ${JSON.stringify(side.anchor)}` +
|
|
56
|
+
(typeof side.anchor === 'string' ? ` (to anchor on it, list it in the library: aqueous([..., '${side.anchor}']), say)` : ''),
|
|
57
|
+
);
|
|
54
58
|
if (!(sp.z !== 0)) fail(`${path}.anchor: '${side.anchor}' is neutral, so it has no level in volts`);
|
|
55
59
|
const entry = mat.species?.[side.anchor];
|
|
56
|
-
if (!isObject(entry)) fail(`${path}.anchor: '${side.anchor}' is absent from '${side.material}'`);
|
|
60
|
+
if (!isObject(entry)) fail(`${path}.anchor: '${side.anchor}' is absent from '${side.material}' (to anchor on it, give the material that species)`);
|
|
57
61
|
if (!Number.isFinite(entry.mu0)) fail(`${path}.anchor: '${side.anchor}' in '${side.material}' has no mu0`);
|
|
58
62
|
return entry.mu0 / (sp.z * FARADAY) - side.offset;
|
|
59
63
|
}
|
package/src/lawRows.js
CHANGED
|
@@ -59,7 +59,9 @@
|
|
|
59
59
|
// affinity pin: fixedA − Σ ν η at the face's two nodes = 0, ∂ = −ν.
|
|
60
60
|
// constraint in a balance row, a pin: amount − ref = 0 (ref: its reference without
|
|
61
61
|
// terminals, else lawRefs; with no reference captured there, none, the amount
|
|
62
|
-
// held where it is), its ∂ from lawAmount.
|
|
62
|
+
// held where it is), its ∂ from lawAmount. A stretch's amount is Σ vol·c over
|
|
63
|
+
// its nodes and A_f Γ_i of each face's diffuse layer at them (s.gam; its ∂
|
|
64
|
+
// by the layer's drop and its edge node's slots). In terminal k's circuit row
|
|
63
65
|
// (in place of what Solver._terminals wrote): the same, its contacts' charges
|
|
64
66
|
// λ·D_in/F added from s.contactQ[t] (as Solver._contact captured them in this
|
|
65
67
|
// assembly), termDI and termDX from the electrodes' charges' ∂/∂V.
|
|
@@ -316,6 +318,28 @@ function lawAmount(s, cs) {
|
|
|
316
318
|
}
|
|
317
319
|
}
|
|
318
320
|
}
|
|
321
|
+
// What the faces' diffuse layers hold of it (src/diffuse.js): A_f Γ_i at their edge nodes, Γ a
|
|
322
|
+
// function of the layer's drop and the edge node's c (through its ∂c_j/∂(φ̂, η): _diffuseChain).
|
|
323
|
+
for (const { stretch, w } of cs.parts) {
|
|
324
|
+
const st = s.stretches[stretch], i = st.species;
|
|
325
|
+
s.diffList.forEach((dl, k) => {
|
|
326
|
+
const g = dl.g;
|
|
327
|
+
if (g < st.nodes[0] || g > st.nodes[1] || !dl.holds[i]) return;
|
|
328
|
+
const f = w * s.model.grid.area[g], b = dl.b, D = dl.dl, dc = s._diffuseChain(k);
|
|
329
|
+
amount += f * s.gam[k * n + i];
|
|
330
|
+
gross += Math.abs(f * s.gam[k * n + i]);
|
|
331
|
+
cs.idx[len] = s.rix[dl.o];
|
|
332
|
+
cs.w[len++] = f * D.dgds[i];
|
|
333
|
+
for (let r = 0; r <= n; r++) {
|
|
334
|
+
if (s.loc[b * M + r] < 0) continue;
|
|
335
|
+
let v = 0;
|
|
336
|
+
for (let j = 0; j < n; j++) v += D.dgdc[i * n + j] * dc[j * M + r];
|
|
337
|
+
if (v === 0) continue;
|
|
338
|
+
cs.idx[len] = s.rix[b * M + r];
|
|
339
|
+
cs.w[len++] = f * v;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
}
|
|
319
343
|
// What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
|
|
320
344
|
for (const { col, w } of cs.surface) {
|
|
321
345
|
const [k, q] = s.surfCols[col], port = s.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
|
|
@@ -362,6 +386,7 @@ function lawStart(s, cs) {
|
|
|
362
386
|
for (const { stretch, w } of cs.parts) {
|
|
363
387
|
const st = s.stretches[stretch], i = st.species;
|
|
364
388
|
for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * s.cOld[g * n + i];
|
|
389
|
+
s.diffList.forEach((dl, k) => dl.g >= st.nodes[0] && dl.g <= st.nodes[1] && (a += w * s.model.grid.area[dl.g] * s.gamOld[k * n + i]));
|
|
365
390
|
}
|
|
366
391
|
for (const { col, w } of cs.surface) {
|
|
367
392
|
const [k, q] = s.surfCols[col], port = s.model.ports[k], G = port.surface[0].capacity;
|
package/src/newton.js
CHANGED
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
// its message.
|
|
21
21
|
// 2. Solved for the update; refined (7) where refining is on.
|
|
22
22
|
// 3. Its size: `step`, the largest |Δ| over φ̂ and each present η at the grid's nodes (a metal's
|
|
23
|
-
// carrier, not its flux slot), each coverage and gate fraction,
|
|
24
|
-
// |ΔV|/V_T; `own`, the same
|
|
23
|
+
// carrier, not its flux slot), each coverage and gate fraction, each diffuse layer's drop and
|
|
24
|
+
// its drawn species' plane levels, and each floating terminal's |ΔV|/V_T; `own`, the same
|
|
25
|
+
// without the terminals and the diffuse layers, each plane level's own update clipped to
|
|
26
|
+
// ±maxStep first.
|
|
25
27
|
// 4. Fails where step isn't finite ('non-finite update'), or where own > 1e4 after the first
|
|
26
28
|
// iteration, or own > 10× the first's after the sixth ('diverging').
|
|
27
29
|
// 5. Added, damped by α = min(1, maxStep/own) (the terminal voltages by the same α), u and uLo
|
|
@@ -283,8 +285,22 @@ function refineSolve(s, dt, delta, deltaV) {
|
|
|
283
285
|
}
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
+
// Each diffuse layer's drawn species' plane level, its update clipped to ±maxStep.
|
|
289
|
+
function clipLevels(s, delta, maxStep) {
|
|
290
|
+
const R = s.rix;
|
|
291
|
+
for (const d of s.diffList) {
|
|
292
|
+
const bf = s.blockOfFace[d.f];
|
|
293
|
+
for (const q of d.drawSlot) {
|
|
294
|
+
if (q < 0) continue;
|
|
295
|
+
const k = R[bf * s.M + q];
|
|
296
|
+
if (Math.abs(delta[k]) > maxStep) delta[k] = Math.sign(delta[k]) * maxStep;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Largest update among the potential-like unknowns (φ̂ and η at grid nodes; layers false leaves out
|
|
302
|
+
// the diffuse layers' drops and plane levels).
|
|
303
|
+
export function maxPotentialStep(s, delta, deltaV = s.deltaV, layers = true) { // (deltaV null: the device's potentials only)
|
|
288
304
|
const { n, M } = s;
|
|
289
305
|
const R = s.rix; // (the sink entry of delta is 0)
|
|
290
306
|
let mx = 0;
|
|
@@ -299,6 +315,11 @@ export function maxPotentialStep(s, delta, deltaV = s.deltaV) { // (deltaV null:
|
|
|
299
315
|
if (k >= 0) for (let q = 0; q < s.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
|
|
300
316
|
}
|
|
301
317
|
for (const gt of s.gateList) mx = Math.max(mx, Math.abs(delta[R[gt.o]])); // a gate's fraction open
|
|
318
|
+
if (layers) for (const d of s.diffList) { // a diffuse layer's drop, and its drawn species' levels at its plane, as η
|
|
319
|
+
const bf = s.blockOfFace[d.f];
|
|
320
|
+
mx = Math.max(mx, Math.abs(delta[R[d.o]]));
|
|
321
|
+
for (const q of d.drawSlot) if (q >= 0) mx = Math.max(mx, Math.abs(delta[R[bf * M + q]]));
|
|
322
|
+
}
|
|
302
323
|
return mx;
|
|
303
324
|
}
|
|
304
325
|
|
|
@@ -484,8 +505,13 @@ export function newton(s, dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands
|
|
|
484
505
|
// linearly (conductance links, held levels, a capacitive face), so a large swing in one is
|
|
485
506
|
// safe, and damping it would throttle the whole update (a current-driven port switching
|
|
486
507
|
// off, its voltage collapsing by hundreds of volts, crawled 10 thermal units at a time).
|
|
508
|
+
// (So do the diffuse layers' drops, set by the potentials, and their drawn species' plane
|
|
509
|
+
// levels, each level's own update clipped to maxStep instead: far from the solution a
|
|
510
|
+
// reaction's rate can ask more of its layer than it can pass, and the level's update then
|
|
511
|
+
// heads for −∞.)
|
|
512
|
+
clipLevels(s, delta, maxStep);
|
|
487
513
|
const step = maxPotentialStep(s, delta, deltaV);
|
|
488
|
-
const own = maxPotentialStep(s, delta, null);
|
|
514
|
+
const own = maxPotentialStep(s, delta, null, false);
|
|
489
515
|
// A refined correction the next update keeps (under a tenth of it) was a lost mode: this
|
|
490
516
|
// device's time steps are refined from now on, a convergence taken only on a refined update.
|
|
491
517
|
if (candidate > 0 && step < 0.1 * candidate) s.lostMode = true;
|
package/src/regions.js
CHANGED
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
// potential; a species no model holds is ideal there;
|
|
21
21
|
// a conductor's node: no bulk concentration; its carrier's c is the excess charge of a charged
|
|
22
22
|
// face's sheet, sheetSign·D·A/(zF·vol);
|
|
23
|
-
// absent species: c = 0; then the coverages (_coverages: Langmuir, θ_s = e^{ζ_s}/(1 + Σe^ζ))
|
|
23
|
+
// absent species: c = 0; then the coverages (_coverages: Langmuir, θ_s = e^{ζ_s}/(1 + Σe^ζ)),
|
|
24
|
+
// and the faces' diffuse layers from their edge nodes' c and their drops (_diffuseLayers in
|
|
25
|
+
// src/boundaries.js: q, Γ and their derivatives, into each layer's scratch and gam).
|
|
24
26
|
// ∂c_i/∂η_j = K_ij, ∂c_i/∂φ̂ = −(Kz)_i (_Kz; _dlnc adds these as ∂ln c, _dc sets them as ∂c).
|
|
25
27
|
// At a non-ideal node of a region with eddy mixing (nodeDK), K's slope dK = ∂K/∂ζ as well.
|
|
26
28
|
//
|
|
@@ -70,8 +72,8 @@
|
|
|
70
72
|
// 2. A segment's flux goes into its two nodes' rows with opposite signs, the same number.
|
|
71
73
|
// 3. At a level μ̄ along a segment (Δη = 0, no velocity), its flux and mixing are exactly 0;
|
|
72
74
|
// at a = 0 a bulk reaction's rate is exactly 0.
|
|
73
|
-
// 4. computeConcentrations writes c, K, ex, th, th0, dK at the nodeDK nodes (and scratch:
|
|
74
|
-
// zeta, the statistics models' work arrays) and nothing else; the kernels write res,
|
|
75
|
+
// 4. computeConcentrations writes c, K, ex, th, th0, gam, dK at the nodeDK nodes (and scratch:
|
|
76
|
+
// zeta, the statistics models' work arrays, the diffuse layers' dl) and nothing else; the kernels write res,
|
|
75
77
|
// sys (or lin), termB/termC (the charge rows), and scratch kept on the Solver (dA, dB, jL,
|
|
76
78
|
// jR, sg, mixWork, mixCrowd) or cached on a material (mobile).
|
|
77
79
|
// 5. The charge rows are an invertible transformation of the equations at the node (the same
|
|
@@ -156,6 +158,7 @@ export class RegionTerms {
|
|
|
156
158
|
}
|
|
157
159
|
}
|
|
158
160
|
this._coverages();
|
|
161
|
+
if (this.diffList.length > 0) this._diffuseLayers();
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
_work(md) {
|
package/src/solution.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
|
|
7
7
|
import { EPS0, FARADAY } from './constants.js';
|
|
8
8
|
|
|
9
|
+
// A diffuse layer's plane past this concentration (mol/m³, 5 M) has its counter-ions closer than
|
|
10
|
+
// hydrated ions pack, beyond what a dilute layer describes.
|
|
11
|
+
const DIFFUSE_CROWDED = 5000;
|
|
12
|
+
|
|
9
13
|
export function makeSolution(solver, result = {}) {
|
|
10
14
|
const { model, n, M, u, c, VT } = solver;
|
|
11
15
|
const { grid, species, contacts, interfaces } = model;
|
|
@@ -184,6 +188,21 @@ export function makeSolution(solver, result = {}) {
|
|
|
184
188
|
out.links = Object.fromEntries(itf.links.flatMap((l, i) => (l.type === 'blocked' ? [] : [[species[i].name, l.type]])));
|
|
185
189
|
const oneWay = solver.faceOneWay(f);
|
|
186
190
|
if (oneWay.some((w) => w)) out.oneWay = Object.fromEntries(oneWay.flatMap((w, i) => (w ? [[species[i].name, w]] : [])));
|
|
191
|
+
// Its diffuse layers (src/diffuse.js), per side: the drop ψ = φ(plane) − φ(edge) (V), the
|
|
192
|
+
// layer's charge (C/m²) and each ion's surface excess (mol/m²), and each ion's concentration
|
|
193
|
+
// at the plane, c e^{−zFψ/RT} (mol/m³), which the face's reactions see.
|
|
194
|
+
solver.diffAt[f].forEach((d, side) => {
|
|
195
|
+
if (d < 0) return;
|
|
196
|
+
const dl = solver.diffList[d], sd = u[dl.o], excess = {}, plane = {};
|
|
197
|
+
for (let i = 0; i < n; i++) {
|
|
198
|
+
if (!solver.present[dl.g * n + i]) continue;
|
|
199
|
+
excess[species[i].name] = solver.gam[d * n + i];
|
|
200
|
+
// (crowded: over D; drawn by a reaction, times e^w, its level there below the edge's)
|
|
201
|
+
const w = dl.drawSlot[i] < 0 ? 0 : u[solver.blockOfFace[f] * solver.M + dl.drawSlot[i]];
|
|
202
|
+
plane[species[i].name] = (c[dl.g * n + i] * Math.exp(-species[i].z * sd + w)) / (dl.holds[i] ? dl.dl.D : 1);
|
|
203
|
+
}
|
|
204
|
+
(out.diffuse ??= {})[side ? 'right' : 'left'] = { psi: solver.VT * sd, charge: dl.dl.q, excess, plane };
|
|
205
|
+
});
|
|
187
206
|
if (itf.gates.length > 0) {
|
|
188
207
|
// Each gate's fraction open, and the voltage across the face that drives them.
|
|
189
208
|
out.gates = Object.fromEntries(itf.gates.map((gate, q) => [gate.name, u[solver._gateSlot(f, q)]]));
|
|
@@ -222,6 +241,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
222
241
|
q += grid.vol[g] * rho;
|
|
223
242
|
}
|
|
224
243
|
interfaces.forEach((itf, f) => (q += itf.sheetCharge * grid.area[grid.regionEnd[f]]));
|
|
244
|
+
solver.diffList.forEach((d) => (q += d.dl.q * grid.area[d.g])); // (the diffuse layers', held below the grid)
|
|
225
245
|
sol.charge = q;
|
|
226
246
|
|
|
227
247
|
// Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
|
|
@@ -272,10 +292,16 @@ export function makeSolution(solver, result = {}) {
|
|
|
272
292
|
'advance() in time instead, or give the still species a small D where it is still.',
|
|
273
293
|
);
|
|
274
294
|
} else if (slow.length > 0) {
|
|
295
|
+
// Too slow to wait out only where the solve waited: where no time step was taken at all, the
|
|
296
|
+
// start was the trouble (a hole-only layer full of the anode's holes, with traps: "raise its
|
|
297
|
+
// rate constant" changed nothing, ×1e4).
|
|
298
|
+
const waited = (result.history ?? []).some((h) => h.converged && h.dt > 0);
|
|
275
299
|
for (const { where, affinity } of slow) {
|
|
276
300
|
sol.warnings.push(
|
|
277
301
|
`the solve did not converge, likely because ${where} is out of equilibrium by ${(affinity * solver.VT * 1e3).toPrecision(3)} mV of affinity, yet nothing else makes or uses what it changes, so a steady state needs it at equilibrium: ` +
|
|
278
|
-
|
|
302
|
+
(waited
|
|
303
|
+
? 'it is too slow for the steady solve to wait out. Raise its rate constant (the steady state does not depend on it), or advance() in time.'
|
|
304
|
+
: 'the solve failed from its first steps, so start it nearer that equilibrium: c0 near it, or solve without the reaction and set() it back.'),
|
|
279
305
|
);
|
|
280
306
|
}
|
|
281
307
|
} else if (result.converged === false && cond && cond.digits > 12) {
|
|
@@ -364,7 +390,8 @@ export function makeSolution(solver, result = {}) {
|
|
|
364
390
|
sol.warnings.push(
|
|
365
391
|
`${where}: the reaction here sees the strictly neutral cell beside the face (${h.toExponential(2)} m), which holds the face's charge as a diffuse layer that wide: ` +
|
|
366
392
|
`${species[ion].name} there is shifted by about ${(100 * shift).toPrecision(2)}%, a Frumkin effect set by the mesh that grows as the cell shrinks. ` +
|
|
367
|
-
|
|
393
|
+
"Give the face a diffuse layer below the grid (diffuse: { epsr: 78.4 }, say, for water), which holds that charge where it belongs on any grid; " +
|
|
394
|
+
`or set the face's zeroCharge near its rest potential (so it holds little charge), coarsen that cell to ${((200 * Math.abs(species[ion].z * sigma)) / (F * s2)).toExponential(1)} m or more, ` +
|
|
368
395
|
'or resolve the double layer (epsr > 0, hmin below the Debye length).',
|
|
369
396
|
);
|
|
370
397
|
}
|
|
@@ -379,7 +406,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
379
406
|
`${where}: the face's charge sits in the strictly neutral cell beside it (${h.toExponential(2)} m), which acts as a diffuse layer that wide ` +
|
|
380
407
|
`(${Cbox.toPrecision(2)} F/m² in series), ${lowers}, so it depends on the mesh. ` +
|
|
381
408
|
(Number.isFinite(C)
|
|
382
|
-
?
|
|
409
|
+
? `${where.startsWith('interfaces') ? 'Give the face a diffuse layer below the grid (diffuse: { epsr }), or coarsen' : 'Coarsen'} the grid there: a cell of ${((2 * 49 * C * RT) / (F * F * s2)).toExponential(1)} m or more keeps it within 2%.`
|
|
383
410
|
: "Give the face a Helmholtz capacitance (phi: { type: 'capacitive', C }), or make it neutral."),
|
|
384
411
|
);
|
|
385
412
|
};
|
|
@@ -389,9 +416,22 @@ export function makeSolution(solver, result = {}) {
|
|
|
389
416
|
check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
|
|
390
417
|
check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
|
|
391
418
|
const reacting = (side) => itf.reactions.flatMap((rx) => rx.part.filter((p) => p.side === side).map((p) => p.i));
|
|
392
|
-
const sigma = sol.interfaces[f].D;
|
|
393
|
-
|
|
394
|
-
boxed(
|
|
419
|
+
const sigma = sol.interfaces[f].D, [dlL, dlR] = solver.diffAt[f];
|
|
420
|
+
// (a side with a diffuse layer holds the face's charge there, below the grid, not in its cell)
|
|
421
|
+
if (dlL < 0) boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`, sigma, reacting(0));
|
|
422
|
+
if (dlR < 0) boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`, sigma, reacting(1));
|
|
423
|
+
// A dilute (Boltzmann) layer piles counter-ions at the plane without limit; past a few molar
|
|
424
|
+
// they'd have no room, and the layer's charge and capacitance are overstated.
|
|
425
|
+
for (const [side, dd] of Object.entries(sol.interfaces[f].diffuse ?? {})) {
|
|
426
|
+
if (itf.diffuse.lattice[side === 'left' ? 0 : 1]) continue; // (a crowded layer can't pass its sites)
|
|
427
|
+
const [name, top] = Object.entries(dd.plane).reduce((a, e) => (e[1] > a[1] ? e : a), ['', 0]);
|
|
428
|
+
if (top > DIFFUSE_CROWDED) {
|
|
429
|
+
sol.warnings.push(
|
|
430
|
+
`interfaces[${f}] (${side} side): the diffuse layer's plane holds ${name} at ${top.toPrecision(3)} mol/m³ (a drop of ${(1e3 * dd.psi).toPrecision(3)} mV), ` +
|
|
431
|
+
'past where ions have room to pack, so this dilute layer overstates its charge and capacitance. A smaller Stern capacitance C, or a zeroCharge nearer the operating potential, keeps the drop modest.',
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
395
435
|
});
|
|
396
436
|
const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
|
|
397
437
|
if (solved && resolves(contacts.left)) {
|