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/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) fail(`${path}.anchor must be 'phi' or a charged species, got ${JSON.stringify(side.anchor)}`);
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. In terminal k's circuit row
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, and each floating terminal's
24
- // |ΔV|/V_T; `own`, the same without the terminals.
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
- // Largest update among the potential-like unknowns (φ̂ and η at grid nodes).
287
- export function maxPotentialStep(s, delta, deltaV = s.deltaV) { // (deltaV null: the device's potentials only)
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
- '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.',
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
- `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, ` +
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
- ? `Coarsen the grid there: a cell of ${((2 * 49 * C * RT) / (F * F * s2)).toExponential(1)} m or more keeps it within 2%.`
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
- boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`, sigma, reacting(0));
394
- boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`, sigma, reacting(1));
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)) {