driftlet 1.2.4 → 1.3.2

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/solver.js CHANGED
@@ -1,232 +1,28 @@
1
- // Butler–Volmer factor g(a) = e^{αa} − e^{−(1−α)a} = e^{−(1−α)a}·expm1(a) (precise near a = 0),
2
- // and its derivative g′(a) = α e^{αa} + (1−α) e^{−(1−α)a}.
3
- function bvFactor(a, alpha) {
4
- const e = Math.exp(-(1 - alpha) * a);
5
- // (far from equilibrium, the plain difference: the product would give 0·∞ there)
6
- const g = Math.abs(a) > 50 ? Math.exp(alpha * a) - e : e * Math.expm1(a);
7
- return { g, gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
8
- }
9
-
10
- // GMRES(m), restarted, for a real system op(x) = b, op already preconditioned; x holds the
11
- // starting guess and receives the answer. Returns whether it converged.
12
- function gmresReal(op, b, x, { m = 10, restarts = 3, tol = 1e-12 } = {}) {
13
- const N = b.length;
14
- const dot = (a, c) => {
15
- let t = 0;
16
- for (let j = 0; j < N; j++) t += a[j] * c[j];
17
- return t;
18
- };
19
- const bn = Math.sqrt(dot(b, b)) || 1;
20
- for (let cycle = 0; cycle < restarts; cycle++) {
21
- const ax = op(x), r = Float64Array.from(b, (v, j) => v - ax[j]);
22
- const beta = Math.sqrt(dot(r, r));
23
- if (!(beta > tol * bn)) return true;
24
- const V = [r.map((v) => v / beta)], H = Array.from({ length: m + 1 }, () => new Float64Array(m));
25
- const cs = [], sn = [], g = new Float64Array(m + 1);
26
- g[0] = beta;
27
- let k = 0, converged = false;
28
- for (; k < m; k++) {
29
- const w = op(V[k]);
30
- for (let i = 0; i <= k; i++) {
31
- const h = dot(V[i], w);
32
- H[i][k] = h;
33
- for (let j = 0; j < N; j++) w[j] -= h * V[i][j];
34
- }
35
- const hn = Math.sqrt(dot(w, w));
36
- V.push(w.map((v) => v / (hn || 1)));
37
- for (let i = 0; i < k; i++) {
38
- const a = H[i][k], c = H[i + 1][k];
39
- H[i][k] = cs[i] * a + sn[i] * c;
40
- H[i + 1][k] = -sn[i] * a + cs[i] * c;
41
- }
42
- const t = Math.hypot(H[k][k], hn), c = t > 0 ? H[k][k] / t : 1, sv = t > 0 ? hn / t : 0;
43
- cs.push(c);
44
- sn.push(sv);
45
- H[k][k] = t;
46
- g[k + 1] = -sv * g[k];
47
- g[k] *= c;
48
- if (!(Math.abs(g[k + 1]) > tol * bn) || !(hn > 0)) {
49
- k++;
50
- converged = true;
51
- break;
52
- }
53
- }
54
- const y = new Float64Array(k);
55
- for (let i = k - 1; i >= 0; i--) {
56
- let v = g[i];
57
- for (let j = i + 1; j < k; j++) v -= H[i][j] * y[j];
58
- y[i] = v / H[i][i];
59
- }
60
- for (let i = 0; i < k; i++) for (let j = 0; j < N; j++) x[j] += y[i] * V[i][j];
61
- if (converged) return true;
62
- }
63
- return false;
64
- }
65
-
66
- // Solve the K×K complex system (Ar + i Ai) x = (last column), partial pivoting by modulus.
67
- function complexSolve(Ar, Ai, K) {
68
- for (let col = 0; col < K; col++) {
69
- let p = col;
70
- for (let r = col + 1; r < K; r++) if (Math.hypot(Ar[r][col], Ai[r][col]) > Math.hypot(Ar[p][col], Ai[p][col])) p = r;
71
- [Ar[col], Ar[p]] = [Ar[p], Ar[col]];
72
- [Ai[col], Ai[p]] = [Ai[p], Ai[col]];
73
- const dr = Ar[col][col], di = Ai[col][col], d2 = dr * dr + di * di;
74
- for (let r = col + 1; r < K; r++) {
75
- const fr = (Ar[r][col] * dr + Ai[r][col] * di) / d2, fi = (Ai[r][col] * dr - Ar[r][col] * di) / d2;
76
- for (let k = col; k <= K; k++) {
77
- const ur = Ar[col][k], ui = Ai[col][k];
78
- Ar[r][k] -= fr * ur - fi * ui;
79
- Ai[r][k] -= fr * ui + fi * ur;
80
- }
81
- }
82
- }
83
- const re = new Float64Array(K), im = new Float64Array(K);
84
- for (let r = K - 1; r >= 0; r--) {
85
- let vr = Ar[r][K], vi = Ai[r][K];
86
- for (let k = r + 1; k < K; k++) {
87
- vr -= Ar[r][k] * re[k] - Ai[r][k] * im[k];
88
- vi -= Ar[r][k] * im[k] + Ai[r][k] * re[k];
89
- }
90
- const dr = Ar[r][r], di = Ai[r][r], d2 = dr * dr + di * di;
91
- re[r] = (vr * dr + vi * di) / d2;
92
- im[r] = (vi * dr - vr * di) / d2;
93
- }
94
- return { re, im };
95
- }
96
-
97
- // GMRES(m), restarted, for a complex system op(x) = b, with vectors as [re, im] pairs and op
98
- // already preconditioned; x holds the starting guess and receives the answer. A cycle's own
99
- // residual estimate can drift far from the true one (a preconditioner nearly singular at low
100
- // frequency, or an op whose round-off the preconditioner amplifies), so each cycle is judged by
101
- // the true residual, at the next one's start: it returns once that's below tol, and gives up when
102
- // two cycles in a row fail to lower it, leaving x at the best iterate. Returns the true residuals (relative
103
- // to b) at the start and at that iterate.
104
- function gmres(op, b, x, { m = 12, restarts = 8, tol = 1e-8 } = {}) {
105
- const N = b[0].length;
106
- const cdot = (a, c) => {
107
- let re = 0, im = 0;
108
- for (let j = 0; j < N; j++) {
109
- re += a[0][j] * c[0][j] + a[1][j] * c[1][j];
110
- im += a[0][j] * c[1][j] - a[1][j] * c[0][j];
111
- }
112
- return [re, im]; // conj(a)·c
113
- };
114
- const norm = (a) => Math.sqrt(cdot(a, a)[0]);
115
- const bn = norm(b) || 1;
116
- const best = [Float64Array.from(x[0]), Float64Array.from(x[1])];
117
- let initial = Infinity, least = Infinity, idle = 0;
118
- for (let cycle = 0; ; cycle++) {
119
- const ax = op(x), r = [Float64Array.from(b[0], (v, j) => v - ax[0][j]), Float64Array.from(b[1], (v, j) => v - ax[1][j])];
120
- const beta = norm(r) / bn;
121
- if (cycle === 0) initial = beta;
122
- idle = beta < least ? 0 : idle + 1;
123
- if (idle === 0) {
124
- least = beta;
125
- best[0].set(x[0]);
126
- best[1].set(x[1]);
127
- }
128
- if (!(beta > tol) || cycle === restarts || idle === 2) break;
129
- const V = [[r[0].map((v) => v / (beta * bn)), r[1].map((v) => v / (beta * bn))]];
130
- const H = Array.from({ length: m + 1 }, () => Array.from({ length: m }, () => [0, 0]));
131
- const cs = [], sn = [], g = Array.from({ length: m + 1 }, () => [0, 0]);
132
- g[0] = [beta * bn, 0];
133
- let k = 0;
134
- for (; k < m; k++) {
135
- const w = op(V[k]);
136
- for (let i = 0; i <= k; i++) {
137
- const [hr, hi] = cdot(V[i], w);
138
- H[i][k] = [hr, hi];
139
- for (let j = 0; j < N; j++) {
140
- w[0][j] -= hr * V[i][0][j] - hi * V[i][1][j];
141
- w[1][j] -= hr * V[i][1][j] + hi * V[i][0][j];
142
- }
143
- }
144
- const hn = norm(w);
145
- H[k + 1][k] = [hn, 0];
146
- V.push([w[0].map((v) => v / (hn || 1)), w[1].map((v) => v / (hn || 1))]);
147
- // The earlier rotations, then a new one zeroing H[k+1][k]: x' = c x + s y, y' = −s̄ x + c y.
148
- for (let i = 0; i < k; i++) {
149
- const [xr, xi] = H[i][k], [yr, yi] = H[i + 1][k], c = cs[i], [sr, si] = sn[i];
150
- H[i][k] = [c * xr + sr * yr - si * yi, c * xi + sr * yi + si * yr];
151
- H[i + 1][k] = [-(sr * xr + si * xi) + c * yr, -(sr * xi - si * xr) + c * yi];
152
- }
153
- const [ar, ai] = H[k][k], am = Math.hypot(ar, ai), t = Math.hypot(am, hn);
154
- const c = t > 0 ? am / t : 1, s = am > 0 ? [(ar / am) * (hn / t), (ai / am) * (hn / t)] : [1, 0];
155
- cs.push(c);
156
- sn.push(s);
157
- H[k][k] = am > 0 ? [(ar / am) * t, (ai / am) * t] : [hn, 0];
158
- H[k + 1][k] = [0, 0];
159
- const [gr, gi] = g[k];
160
- g[k] = [c * gr, c * gi];
161
- g[k + 1] = [-(s[0] * gr + s[1] * gi), -(s[0] * gi - s[1] * gr)];
162
- if (!(Math.hypot(g[k + 1][0], g[k + 1][1]) > tol * bn)) {
163
- k++;
164
- break;
165
- }
166
- }
167
- // Back-substitute H y = g over the first k columns, then x += V y.
168
- const y = Array.from({ length: k }, () => [0, 0]);
169
- for (let i = k - 1; i >= 0; i--) {
170
- let [vr, vi] = g[i];
171
- for (let j = i + 1; j < k; j++) {
172
- const [hr, hi] = H[i][j], [yr, yi] = y[j];
173
- vr -= hr * yr - hi * yi;
174
- vi -= hr * yi + hi * yr;
175
- }
176
- const [dr, di] = H[i][i], d2 = dr * dr + di * di;
177
- y[i] = [(vr * dr + vi * di) / d2, (vi * dr - vr * di) / d2];
178
- }
179
- for (let i = 0; i < k; i++) {
180
- const [yr, yi] = y[i];
181
- for (let j = 0; j < N; j++) {
182
- x[0][j] += yr * V[i][0][j] - yi * V[i][1][j];
183
- x[1][j] += yr * V[i][1][j] + yi * V[i][0][j];
184
- }
185
- }
186
- }
187
- x[0].set(best[0]);
188
- x[1].set(best[1]);
189
- return { initial, residual: least };
190
- }
191
1
 
192
- // Discretisation and nonlinear solver.
193
- //
194
- // Unknowns, per solver block (M = 1 + nSpecies slots):
195
- // grid node: [φ̂, η_1 … η_n], with φ̂ = Fφ/RT and η_i = μ̄_i/RT
196
- // flux node: [D, N_1 … N_n, r_1 … r_K], the displacement and particle fluxes through an
197
- // interface, and the rate of each reaction at it
198
- // Every region boundary is a doubled grid node (one per side) with a zero-volume flux node
199
- // between them in the linear system, so the Jacobian stays block-tridiagonal.
200
- //
201
- // Not every slot is an unknown everywhere: an absent species, φ where it's undefined, a blocked
202
- // interface flux. The state u keeps every slot (one that isn't an unknown just keeps its value),
203
- // but the residual, the update and the Jacobian hold only the unknowns: block b has as many as
204
- // it has active slots, and the linear system's block sizes vary from node to node. `loc` maps a
205
- // slot to its row within its block (or −1), and `rix` to its index in the compact vectors (or
206
- // the sink, one spare entry at the end that absorbs writes for slots that aren't unknowns).
207
- //
208
- // Balance rows (node g, box volume v per unit area):
209
- // φ: D_out − D_in − v·(F Σ z_i c_i + ρ_fixed) = 0
210
- // i: v·(c_i − c_i,old)/dt + N_out − N_in = 0
211
- // Fluxes along segments are Scharfetter–Gummel. Each flux is computed once per segment and
212
- // added with opposite signs to both neighbours, so sums over boxes telescope exactly.
213
- //
214
- // Concentrations come from each material's statistics, c(ζ) with ζ_i = η_i − μ°_i/RT − z_i φ̂,
215
- // and K = ∂c/∂ζ. Nodes of ideal materials (c = c_ref e^ζ) take a fast path throughout.
216
-
217
- import { BlockTridiagonal, ComplexBlockTridiagonal } from './blockTridiagonal.js';
218
- import { bernoulli, bernoulliDerivative } from './bernoulli.js';
219
- import { EPS0, FARADAY } from './constants.js';
2
+ // The numerical core: the Solver's state (laid out in docs/internals.md), its setup, and the
3
+ // drivers around the modules that state their contracts: the equations (src/assemble.js, with
4
+ // src/regions.js and src/boundaries.js, whose methods are the Solver's own), Newton
5
+ // (src/newton.js), time steps (src/timeSteps.js), steady solves (src/steady.js) and the small
6
+ // signal (src/impedance.js), the Solver's own too, the cold start (src/start.js) and the laws
7
+ // (src/laws.js, src/lawRows.js).
8
+
9
+ import { Assembly } from './assemble.js';
10
+ import { BlockTridiagonal } from './blockTridiagonal.js';
11
+ import { bernoulli, SG_FAR, sgFlux } from './bernoulli.js';
12
+ import { BoundaryTerms } from './boundaries.js';
13
+ import { FARADAY } from './constants.js';
220
14
  import { sourceAt } from './device.js';
221
- import { DifferenceTerms } from './difference.js';
222
15
  import { DeviceError } from './errors.js';
223
- import { chargeOnly, conservedMoieties, feeds, findLaws, noCurrent } from './laws.js';
224
- import { applyLaws, captureLaws, captureLocal, snapFlat, solveBordered } from './lawRows.js';
225
- import { advance as advanceFn, integrate as integrateFn, step as stepFn, violentStart } from './timeSteps.js';
226
- import { powi, powr } from './pow.js';
16
+ import { conservedMoieties, findLaws, noCurrent } from './laws.js';
17
+ import { newton as newtonFn } from './newton.js';
18
+ import { RegionTerms } from './regions.js';
19
+ import { SteadySolves } from './steady.js';
20
+ import { SmallSignal } from './impedance.js';
21
+ import { advance as advanceFn, integrate as integrateFn, step as stepFn } from './timeSteps.js';
22
+ import { initFromComposition as initFromCompositionFn } from './start.js';
227
23
 
228
24
  // A profile's value at x (c0, kf): piecewise linear, constant beyond its ends.
229
- function profileAt({ x, values: c }, at) {
25
+ export function profileAt({ x, values: c }, at) {
230
26
  if (at <= x[0]) return c[0];
231
27
  const last = x.length - 1;
232
28
  if (at >= x[last]) return c[last];
@@ -264,26 +60,6 @@ export class SolverError extends Error {
264
60
  }
265
61
  }
266
62
 
267
- // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
268
- function gateRate(law, V) {
269
- const { rate, midpoint, scale } = law;
270
- if (law.type === 'exp') {
271
- // (held finite where Newton's iterates stray to absurd voltages: β_m at −13 V would overflow)
272
- const y = (V - midpoint) / scale, v = rate * Math.exp(Math.min(y, 700));
273
- return [v, y > 700 ? 0 : v / scale];
274
- }
275
- if (law.type === 'sigmoid') {
276
- const e = Math.exp((midpoint - V) / scale), d = 1 + e;
277
- return Number.isFinite(e) ? [rate / d, (rate * e) / (scale * d * d)] : [0, 0];
278
- }
279
- // expLinear: rate·y/(1 − e^−y), smooth through y = 0 (rate there) and → rate·y for large y
280
- const y = (V - midpoint) / scale;
281
- if (Math.abs(y) < 1e-6) return [rate * (1 + y / 2), rate / (2 * scale)];
282
- if (y < -700) return [0, 0];
283
- const em = -Math.expm1(-y), ey = Math.exp(-y); // 1 − e^−y, e^−y
284
- return [(rate * y) / em, (rate * (em - y * ey)) / (em * em * scale)];
285
- }
286
-
287
63
  export class Solver {
288
64
  constructor(model) {
289
65
  this.model = model;
@@ -449,6 +225,13 @@ export class Solver {
449
225
  });
450
226
  this._activeSlots();
451
227
  this.K = this.anyNonIdeal ? new Float64Array(nNodes * n * n) : null;
228
+ // Crowded nodes under eddy mixing: K's slope too (_statSlopes), for its mobility's Jacobian.
229
+ this.nodeDK = new Uint8Array(nNodes);
230
+ regions.forEach((reg, r) => {
231
+ if (reg.mixing > 0) for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) if (!this.nodeIdeal[g]) this.nodeDK[g] = 1;
232
+ });
233
+ this.dK = this.nodeDK.some((v) => v) ? new Float64Array(nNodes * n * n * n) : null;
234
+ if (!this.dK) this.nodeDK = null;
452
235
  this.ex = new Float64Array(nNodes * n);
453
236
  this.zeta = new Float64Array(n);
454
237
  this.scratch = new Map(); // per statistics model: ζ, c, K work arrays
@@ -456,6 +239,7 @@ export class Solver {
456
239
  this.dB = new Float64Array(M);
457
240
  this.jL = new Float64Array(M);
458
241
  this.jR = new Float64Array(M);
242
+ this.sg = new Float64Array(5); // a Scharfetter–Gummel flux and its derivatives (sgFlux)
459
243
  this.time = 0;
460
244
  this.lastDt = Infinity;
461
245
  this.atSteady = false; // whether the state is a converged steady solve
@@ -507,9 +291,15 @@ export class Solver {
507
291
  this.termDX = this.terms.map(() => new Float64Array(this.terms.length));
508
292
  this.termRes = new Float64Array(this.terms.length);
509
293
  this._refreshSources();
510
- // A floating terminal starts level with the first held one (no current).
511
- const level = this.termV[this.terms.findIndex((_, k) => !this.floating.includes(k))];
512
- for (const k of this.floating) this.termV[k] = this.terms[k].drive.kind === 'V' ? this.termV[k] : level;
294
+ // A floating terminal starts level with the first held one (no current); where none is held,
295
+ // with the source behind the first resistance (it was NaN there, and a metal contact's
296
+ // carrier, its level from that V, asked for a c0 a conductor can't be given), else 0.
297
+ const heldK = this.terms.findIndex((_, k) => !this.floating.includes(k)), behindR = this.terms.find((t) => t.drive.R > 0);
298
+ const level = heldK >= 0 ? this.termV[heldK] : behindR ? sourceAt(behindR.drive.src, this.time) : 0;
299
+ // (One behind a resistance too: at its own source, a silver cell held at 0 V and 16 V behind R
300
+ // began with 16 V across it; left at 0 V, one behind a −30 V source beside a current began
301
+ // with 30 V across it. Level, the circuit rows move them from a device at rest.)
302
+ for (const k of this.floating) this.termV[k] = level;
513
303
  // Accepted steps, most recent first: start time, size, start state (for BDF2 and the
514
304
  // error estimate of adaptive stepping).
515
305
  this.history = [];
@@ -533,10 +323,12 @@ export class Solver {
533
323
  nu: Float64Array.from([...rx.reactants.map((p) => p.nu), ...rx.products.map((p) => -p.nu)]),
534
324
  }));
535
325
 
536
- // Each reacting port's electrode area per volume at its window's nodes (a profile's mean over
537
- // each node's box, as for a rate constant), and its reactions' rates there, mol/(m²·s).
326
+ // Each reacting port's electrode area per volume at its window's nodes: a profile's mean over
327
+ // each node's box, as for a rate constant, with no electrode outside the window (the profile's
328
+ // mean over the part inside, times that part's share of the box, port.fill). And its
329
+ // reactions' rates there, mol/(m²·s).
538
330
  this.portArea = model.ports.map((port) =>
539
- port.area === null ? null : Float64Array.from(port.nodes, (g) => port.area.value ?? profileMean(port.area, gx[g] - (g > 0 ? segLength[g - 1] : 0) / 2, gx[g] + (g < segLength.length ? segLength[g] : 0) / 2)),
331
+ port.area === null ? null : Float64Array.from(port.nodes, (g, w) => (port.area.value ?? profileMean(port.area, port.lo[w], port.hi[w])) * port.fill[w]),
540
332
  );
541
333
  this.portRates = model.ports.map((port) => port.reactions.map(() => new Float64Array(port.nodes.length)));
542
334
 
@@ -566,7 +358,7 @@ export class Solver {
566
358
  this.closedEnd = { left: closed('left'), right: closed('right') };
567
359
  held[0] = this.closedEnd.left ? 0 : 1;
568
360
  held[nNodes - 1] = this.closedEnd.right ? 0 : 1;
569
- for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) for (const g of port.nodes) held[g] = 1;
361
+ for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) port.nodes.forEach((g, w) => port.inside[w] && (held[g] = 1));
570
362
  this.chargeNode = new Uint8Array(nNodes);
571
363
  const neutralFace = (f) => (f === -1 ? this.closedEnd.left : f === model.interfaces.length ? this.closedEnd.right : model.interfaces[f].phi.type === 'neutral');
572
364
  regions.forEach((reg, r) => {
@@ -596,7 +388,8 @@ export class Solver {
596
388
  this.portHeldEnd = new Uint8Array(2 * n);
597
389
  model.ports.forEach((port) => {
598
390
  [0, nNodes - 1].forEach((g, k) => {
599
- if (!port.nodes.includes(g)) return;
391
+ const w = port.nodes.indexOf(g);
392
+ if (w < 0 || !port.inside[w]) return;
600
393
  const ct = contacts[k === 0 ? 'left' : 'right'];
601
394
  for (let i = 0; i < n; i++) if (port.species[i].type === 'equilibrium' && ct.species[i].type === 'blocked') this.portHeldEnd[k * n + i] = 1;
602
395
  });
@@ -844,13 +637,6 @@ export class Solver {
844
637
  return { model, n, M, z, nNodes, stretches, stretchOf, flatStretches, rough, surfCols, surfColOf, terms, floating, blockOfNode, blockOfFace, rxsIn, nodeMaterial, phiUndefined };
845
638
  }
846
639
 
847
- // Every stretch either reaches a contact or is kept by a law, and no law is left unkept: the
848
- // steady equations can be solved directly (dt = ∞), with the laws in place of their rows. Else
849
- // the steady solve goes by time steps, which keep every amount themselves.
850
- _directSteady() {
851
- return this.unkept.length === 0 && this.stretches.every((st) => st.connected || st.conserved);
852
- }
853
-
854
640
  // An electrode surface at its starting coverages at node g: η = μ°/RT + ln(θ/θ₀).
855
641
  _surfaceStart(g) {
856
642
  const { n, M, u, model } = this, surf = model.ports[this.surfPort[g]].surface, b = this.blockOfNode[g];
@@ -890,50 +676,6 @@ export class Solver {
890
676
  this.history = [];
891
677
  }
892
678
 
893
- // After a steady solve: each reaction that alone changes some combination nothing else feeds
894
- // (see src/laws.js), whose total rate a steady state therefore sets to zero, but whose affinity has one
895
- // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
896
- // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
897
- // port's slow couple at its starting composition, each converged without a word. (A face
898
- // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
899
- stuckReactions() {
900
- const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
901
- const passes = (k) => !noCurrent(this.terms[k]);
902
- const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && this.terms.some((t, j) => t.drive.kind === 'I' && passes(j) && feeds(j, this.stretches[k])))));
903
- const needed = (rx) => {
904
- const basis = conservedMoieties(this._structure(), { drives: true, skip: rx });
905
- return basis.some((w) => !fed(w) && basis.skipped.some((row) => Math.abs(row.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9));
906
- };
907
- const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
908
- const report = (where, as) => {
909
- if (as.length === 0 || !(as.every((a) => a > 1e-6) || as.every((a) => a < -1e-6))) return;
910
- out.push({ where, affinity: as.reduce((m, a) => (Math.abs(a) < Math.abs(m) ? a : m)) });
911
- };
912
- model.interfaces.forEach((itf, f) => {
913
- const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
914
- itf.reactions.forEach((rx, q) => {
915
- if (this.affinities.some((af) => af.rx === rx) || !needed(rx)) return;
916
- report(`interfaces[${f}].reactions[${q}]`, [rx.part.reduce((a, p) => a - p.nu * eta(p.side ? gR : gL, 1 + p.i), rx.fixedA)]);
917
- });
918
- });
919
- model.reactions.forEach((mrx, x) => {
920
- const rx = this.rxs[x];
921
- if (rx.generation || !needed(mrx)) return;
922
- const as = [];
923
- for (let g = 0; g < this.nNodes; g++) if (rx.kfNode[g] > 0 || rx.srh?.[this.nodeMaterial[g]]) as.push(rx.sp.reduce((a, i, p) => a + rx.nu[p] * eta(g, 1 + i), rx.fixedA));
924
- report(`bulkReactions[${x}]`, as);
925
- });
926
- model.ports.forEach((port, k) => {
927
- port.reactions.forEach((rx, q) => {
928
- if (!needed(rx)) return;
929
- let aV = rx.fixedA;
930
- for (const p of rx.part) if (p.side === 1) aV -= p.nu * this.portEta(port, p.i);
931
- report(`ports[${k}].reactions[${q}]`, port.nodes.map((g) => rx.part.reduce((a, p) => (p.side === 1 ? a : a - p.nu * eta(g, p.side === 2 ? 1 + n + p.s : 1 + p.i)), aV)));
932
- });
933
- });
934
- return out;
935
- }
936
-
937
679
  // How each terminal is driven, as far as the laws care (see src/laws.js): by a voltage or a current,
938
680
  // behind a resistance or not, and a current that's held at zero throughout (which feeds a
939
681
  // combination nothing, so a reaction's affinity may stand in for its summed balance).
@@ -965,2336 +707,138 @@ export class Solver {
965
707
  return z === 0 ? link.mu / this.model.RT : (z * (this.termV[side === 'left' ? 0 : 1] + link.offset)) / this.VT;
966
708
  }
967
709
 
968
- /**
969
- * Cold start. Each species takes its region's c0 where one is given (for a spectator, it fixes
970
- * the conserved amount), else its contact's level. φ in each region is then chosen for local neutrality (node by node where a
971
- * profile varies), or continued across the interface dipole if nothing there responds.
972
- */
710
+ // The cold start (src/start.js).
973
711
  initFromComposition() {
974
- const { model, n, M, u, z } = this;
975
- const { regions, species, interfaces, materials } = model;
976
- const grid = model.grid;
977
- u.fill(0);
978
- this.uLo.fill(0);
979
- const eta = new Float64Array(n), cFix = new Float64Array(n), mode = new Int8Array(n); // 1 level, 2 amount
980
- // φ̂ runs from the left: from a pinned (or gated) left contact's φ at its zero charge, else 0.
981
- const leftPhi = model.contacts.left.phi;
982
- let phiHat = leftPhi.type === 'pinned' || leftPhi.type === 'capacitive' ? (this.termV[0] - leftPhi.zeroCharge) / this.VT : 0;
983
- for (let r = 0; r < regions.length; r++) {
984
- if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
985
- const reg = regions[r], mat = materials[reg.material];
986
- mode.fill(0);
987
- const profiled = reg.c0Profile.some((p) => p !== null);
988
- for (let i = 0; i < n; i++) {
989
- if (!mat.present[i]) continue;
990
- const st = this.stretches[this.stretchOf[r * n + i]];
991
- if (reg.c0Profile[i] || reg.c0[i] > 0) {
992
- // A given c0 is the starting state, even where a contact feeds the species.
993
- mode[i] = 2;
994
- cFix[i] = reg.c0Profile[i] ? profileAt(reg.c0Profile[i], grid.x[grid.regionStart[r]]) : reg.c0[i];
995
- } else if (!st.contactFed && mat.conductor) {
996
- // A conductor away from the contacts: start uncharged, with its carrier's level in
997
- // equilibrium with the first reaction on its left face that takes it, else at the
998
- // running φ.
999
- eta[i] = z[i] * phiHat;
1000
- const rx = r > 0 ? interfaces[r - 1].reactions.find((x) => x.part.some((p) => p.side === 1 && p.i === i)) : undefined;
1001
- if (rx) {
1002
- const bn = this.blockOfNode[grid.regionEnd[r - 1]];
1003
- let a = rx.fixedA, nu = 0; // a = fixedA − Σ ν η = 0, solved for the carrier's η
1004
- for (const p of rx.part) {
1005
- if (p.side === 1) nu += p.nu;
1006
- else a -= p.nu * u[bn * M + 1 + p.i];
1007
- }
1008
- eta[i] = a / nu;
1009
- }
1010
- mode[i] = 1;
1011
- } else if (!st.contactFed) {
1012
- if (!(reg.c0[i] > 0)) {
1013
- throw new SolverError(
1014
- `regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration` +
1015
- (st.connected ? ' (reactions make and consume it, so this is only where the solve starts, not an amount it keeps: a tiny value will do)' : ', which fixes the amount it conserves'),
1016
- );
1017
- }
1018
- mode[i] = 2;
1019
- cFix[i] = reg.c0[i];
1020
- } else {
1021
- const left = st.regions[0] === 0 ? this.contactEta('left', i) : NaN;
1022
- eta[i] = Number.isFinite(left) ? left : this.contactEta('right', i);
1023
- if (!Number.isFinite(eta[i]) && st.linked.length > 0) eta[i] = this.portEta(model.ports[st.linked[0]], i);
1024
- mode[i] = 1;
1025
- if (!Number.isFinite(eta[i])) {
1026
- // Fed only through reactions (no level held at a contact): start from c0.
1027
- if (!(reg.c0[i] > 0)) {
1028
- throw new SolverError(
1029
- `regions[${r}].c0.${species[i].name}: a species fed only through reactions needs its initial concentration`,
1030
- );
1031
- }
1032
- mode[i] = 2;
1033
- cFix[i] = reg.c0[i];
1034
- }
1035
- }
1036
- }
1037
- // Net charge (mol/m³) at trial φ̂; decreasing in φ̂ wherever a level-fixed ion responds.
1038
- const zeta = new Float64Array(n), cc = new Float64Array(n);
1039
- const charge = (ph) => {
1040
- if (!mat.ideal) {
1041
- this._materialAt(mat, reg.background, eta, mode, cFix, ph, zeta, cc);
1042
- let q = reg.fixedCharge / FARADAY;
1043
- for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
1044
- return q;
1045
- }
1046
- let q = reg.fixedCharge / FARADAY;
1047
- for (let i = 0; i < n; i++) {
1048
- if (mode[i] === 2) q += z[i] * cFix[i];
1049
- else if (mode[i] === 1 && z[i] !== 0) {
1050
- const ex = Math.min(700, Math.max(-700, eta[i] - mat.mu0[i] / model.RT - z[i] * ph));
1051
- q += z[i] * mat.cRef[i] * Math.exp(ex);
1052
- }
1053
- }
1054
- return q;
1055
- };
1056
- const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
1057
- const mustBalance = !responds && !mat.conductor && !mat.phiFree && mat.epsr === 0;
1058
- const checkNeutral = (where) => {
1059
- // Strictly neutral, with every charged species' amount given: its c0 must be neutral.
1060
- let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
1061
- for (let i = 0; i < n; i++) {
1062
- if (mode[i] !== 2 || z[i] === 0) continue;
1063
- q += z[i] * cFix[i];
1064
- scale += Math.abs(z[i] * cFix[i]);
1065
- }
1066
- if (Math.abs(q) > 1e-9 * scale) {
1067
- throw new SolverError(
1068
- `regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³${where} (Σ z·c0, with any fixed charge), ` +
1069
- 'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
1070
- );
1071
- }
1072
- };
1073
- if (mustBalance && !profiled) checkNeutral('');
1074
- if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
1075
- // Nothing here fixes φ, but an electrode on the left does: start with its first reaction
1076
- // that takes a species from this side at equilibrium (the electrode at its open-circuit
1077
- // level), rather than φ carried over from the metal, which can be volts away.
1078
- const bn = this.blockOfNode[grid.regionEnd[r - 1]];
1079
- for (const rx of interfaces[r - 1].reactions) {
1080
- let a = rx.fixedA, s = 0;
1081
- for (const p of rx.part) {
1082
- if (p.side === 0) a -= p.nu * u[bn * M + 1 + p.i];
1083
- else if (mode[p.i] === 2) {
1084
- a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
1085
- s += p.nu * z[p.i];
1086
- } else s = NaN;
1087
- }
1088
- if (s !== 0 && Number.isFinite(s)) {
1089
- phiHat = a / s; // a − s·φ̂ = 0
1090
- break;
1091
- }
1092
- }
1093
- }
1094
- const electrode = model.ports.find((port) => port.region === r && port.reactions.length > 0);
1095
- if (!mat.phiFree && !responds && mat.ideal && electrode) {
1096
- // Nor here, but an electrode spread through it does: start with the port's first reaction
1097
- // that can be balanced at equilibrium with the port's level (the electrode at open circuit).
1098
- const bare = 1 - electrode.surface.reduce((t, sp) => t + sp.theta0, 0);
1099
- for (const rx of electrode.reactions) {
1100
- let a = rx.fixedA, s = 0;
1101
- for (const p of rx.part) {
1102
- if (p.side === 1) a -= p.nu * this.portEta(electrode, p.i);
1103
- else if (p.side === 2) {
1104
- const sp = electrode.surface[p.s]; // (at its starting coverage)
1105
- a -= p.nu * (sp.mu0 / model.RT + Math.log(sp.theta0 / bare));
1106
- } else if (mode[p.i] === 2) {
1107
- a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
1108
- s += p.nu * z[p.i];
1109
- } else s = NaN;
1110
- }
1111
- if (s !== 0 && Number.isFinite(s)) {
1112
- phiHat = a / s;
1113
- break;
1114
- }
1115
- }
1116
- }
1117
- const neutralPhi = (ph) => {
1118
- let lo = ph - 1, hi = ph + 1;
1119
- while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
1120
- while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
1121
- // Out of reach (every responding carrier has one sign and nothing balances it, as in an
1122
- // undoped layer that holds only holes): keep the running φ̂ rather than run it to ±∞.
1123
- if (charge(lo) < 0 || charge(hi) > 0) return ph;
1124
- for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
1125
- const m = 0.5 * (lo + hi);
1126
- if (charge(m) > 0) lo = m;
1127
- else hi = m;
1128
- }
1129
- return 0.5 * (lo + hi);
1130
- };
1131
- if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
1132
- if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
1133
- for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
1134
- if (profiled) {
1135
- for (let i = 0; i < n; i++) if (reg.c0Profile[i]) cFix[i] = profileAt(reg.c0Profile[i], grid.x[g]);
1136
- if (mustBalance) checkNeutral(` at x = ${grid.x[g].toPrecision(4)} m`);
1137
- if (!mat.phiFree && responds) phiHat = neutralPhi(phiHat);
1138
- if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
1139
- }
1140
- const b = this.blockOfNode[g];
1141
- u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
1142
- for (let i = 0; i < n; i++) {
1143
- if (mode[i] === 1) u[b * M + 1 + i] = eta[i];
1144
- else if (mode[i] === 2) {
1145
- const zt = mat.ideal ? Math.log(cFix[i] / mat.cRef[i]) : zeta[i];
1146
- u[b * M + 1 + i] = zt + mat.mu0[i] / model.RT + z[i] * phiHat;
1147
- }
1148
- }
1149
- }
1150
- }
1151
- // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
1152
- // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
1153
- // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
1154
- // first step made to take that jump met an exactly singular pivot. Start there: the ions the
1155
- // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
1156
- // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
1157
- // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
1158
- // the two nodes' charge summing to what it was, zero.
1159
- for (let f = 0; f < interfaces.length; f++) {
1160
- const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
1161
- const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
1162
- if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
1163
- const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = this.blockOfNode[gL] * M, bR = this.blockOfNode[gR] * M;
1164
- const pass = [];
1165
- for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
1166
- if (!pass.some((i) => z[i] !== 0)) continue;
1167
- const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
1168
- const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
1169
- const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
1170
- let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
1171
- for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
1172
- // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
1173
- const split = (D) =>
1174
- pass.map((i, j) => {
1175
- const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
1176
- return [K * cR, cR];
1177
- });
1178
- const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
1179
- const D0 = u[bR] - u[bL];
1180
- let lo = D0 - 1, hi = D0 + 1;
1181
- while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
1182
- while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
1183
- if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
1184
- for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
1185
- const m = 0.5 * (lo + hi);
1186
- if (qL(m) < 0) lo = m;
1187
- else hi = m;
1188
- }
1189
- const D = 0.5 * (lo + hi), cs = split(D);
1190
- const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
1191
- u[bR] = u[bL] + D;
1192
- for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
1193
- pass.forEach((i, j) => {
1194
- u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
1195
- u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
1196
- });
1197
- }
1198
- // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
1199
- // splits between its ion and its electrons. Start its ion level with the same ion's beside it
1200
- // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
1201
- // and a floating contact on them, start where the cell's open-circuit voltage puts them, not
1202
- // volts away. (A shift of φ̂ and each η by z·Δ: the concentrations don't change.)
1203
- regions.forEach((reg, r) => {
1204
- const mat = materials[reg.material];
1205
- if (!mat.phiFree) return;
1206
- const sides = [[r - 1, grid.regionStart[r], r > 0 ? grid.regionEnd[r - 1] : -1], [r + 1, grid.regionEnd[r], r + 1 < regions.length ? grid.regionStart[r + 1] : -1]];
1207
- for (const [nr, gIn, gOut] of sides) {
1208
- if (gOut < 0) continue;
1209
- const other = materials[regions[nr].material];
1210
- if (other.phiFree || other.conductor) continue;
1211
- const i = z.findIndex((zi, j) => zi !== 0 && mat.present[j] && other.present[j]);
1212
- if (i < 0) continue;
1213
- const d = (u[this.blockOfNode[gOut] * M + 1 + i] - u[this.blockOfNode[gIn] * M + 1 + i]) / z[i];
1214
- if (!Number.isFinite(d)) continue;
1215
- for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
1216
- const b = this.blockOfNode[g];
1217
- u[b * M] += d;
1218
- for (let j = 0; j < n; j++) if (mat.present[j]) u[b * M + 1 + j] += z[j] * d;
1219
- }
1220
- return;
1221
- }
1222
- });
1223
- // A floating contact facing a held one starts where the start's own composition puts it: the
1224
- // held voltage plus the difference between the two terminal species' levels beside each
1225
- // contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
1226
- // that a transient's first reading means something.
1227
- const level = (side) => {
1228
- const ct = model.contacts[side], i = ct.terminal;
1229
- if (i === null || ct.species[i].type !== 'equilibrium' || z[i] === 0) return NaN;
1230
- const b = this.blockOfNode[side === 'left' ? 0 : grid.nNodes - 1];
1231
- return (this.VT * u[b * M + 1 + i]) / z[i] - ct.species[i].offset;
1232
- };
1233
- for (const k of this.floating) {
1234
- const t = this.terms[k], held = k === 0 ? 1 : 0;
1235
- if (t.kind === 'port' || held >= this.terms.length || this.floating.includes(held)) continue;
1236
- const shift = level(t.side) - level(this.terms[held].side);
1237
- if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1238
- }
1239
- for (let g = 0; g < this.nNodes; g++) if (this.surfPort[g] >= 0) this._surfaceStart(g);
1240
- this.computeConcentrations();
1241
- // A floating capacitance starts uncharged (σ = 0 at the window's mean φ), as the starting
1242
- // composition, neutral without it, assumes; with reactions too, its double layer then charges
1243
- // toward their mixed potential.
1244
- for (const k of this.floating) {
1245
- const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1246
- const gate = t.kind === 'contact' ? model.contacts[t.side].phi : null;
1247
- if (gate?.type === 'capacitive') {
1248
- // A gate contact likewise: at its zero-charge level from the φ beside it (a pinned
1249
- // one's charge is the field's, already zero against a neutral start).
1250
- const g = t.side === 'left' ? 0 : this.nNodes - 1;
1251
- if (!this.phiUndefined[g]) this.termV[k] = gate.zeroCharge + this.VT * u[this.blockOfNode[g] * M];
1252
- continue;
1253
- }
1254
- if (!port?.capacitance) continue;
1255
- let w = 0, sum = 0;
1256
- port.nodes.forEach((g, j) => {
1257
- if (this.phiUndefined[g]) return;
1258
- const a = this.model.grid.vol[g] * this.portArea[t.index][j];
1259
- w += a;
1260
- sum += a * this.VT * u[this.blockOfNode[g] * M];
1261
- });
1262
- if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1263
- }
1264
- // A floating port linked to its carrier (no reactions, no double layer: a wire into an
1265
- // insertion host) starts at that carrier's level across its window, not at 0 V.
1266
- for (const k of this.floating) {
1267
- const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1268
- const i = port?.terminal, link = i === null || i === undefined ? null : port.species[i];
1269
- if (!port || port.reactions.length > 0 || port.capacitance || !link || link.type === 'blocked' || z[i] === 0) continue;
1270
- let w = 0, sum = 0;
1271
- port.nodes.forEach((g, j) => {
1272
- const a = this.model.grid.vol[g] * (this.portArea[t.index]?.[j] ?? 1); // (a window with no area: by volume)
1273
- w += a;
1274
- sum += (a * this.VT * u[this.blockOfNode[g] * M + 1 + i]) / z[i];
1275
- });
1276
- // (Levels at the start mean something only against a held terminal's, as for a contact.)
1277
- const held = [0, 1].find((h) => !this.floating.includes(h) && Number.isFinite(level(this.terms[h].side)));
1278
- if (w > 0 && held !== undefined) this.termV[k] = this.termV[held] + sum / w - (link.offset ?? 0) - level(this.terms[held].side);
1279
- }
1280
- // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
1281
- for (const gt of this.gateList) {
1282
- const { gate, o } = gt, V = this._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
1283
- u[o] = a + b > 0 ? a / (a + b) : 0;
1284
- }
1285
- // A floating electrode spread through a port (without a double layer) starts where its
1286
- // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
1287
- // the start's composition, not level with a held terminal, which can be volts away and pass
1288
- // an absurd current.
1289
- for (const k of this.floating) {
1290
- const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1291
- if (!port || port.reactions.length === 0 || port.capacitance) continue;
1292
- const want = t.drive.kind === 'I' ? sourceAt(t.drive.src, this.time) : 0;
1293
- const current = (V) => {
1294
- this.termV[k] = V;
1295
- let I = 0;
1296
- port.reactions.forEach((rx) => {
1297
- let q = 0;
1298
- for (const p of rx.part) if (p.side === 0) q += p.nu * z[p.i];
1299
- port.nodes.forEach((g, w) => (I += q * FARADAY * this.model.grid.vol[g] * this.portArea[t.index][w] * this._portRate(port, rx, g)));
1300
- });
1301
- return I;
1302
- };
1303
- // Current into the device rises with V (oxidation); bracket, then bisect.
1304
- let lo = this.termV[k] - 1, hi = this.termV[k] + 1;
1305
- while (current(lo) > want && lo > -1e3) lo -= 2 * (hi - lo);
1306
- while (current(hi) < want && hi < 1e3) hi += 2 * (hi - lo);
1307
- for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
1308
- const m = 0.5 * (lo + hi);
1309
- if (current(m) < want) lo = m;
1310
- else hi = m;
1311
- }
1312
- this.termV[k] = 0.5 * (lo + hi);
1313
- }
712
+ return initFromCompositionFn(this);
1314
713
  }
1315
714
 
1316
- // A port reaction's rate per area at node g (mol/(m²·s), forward), at the present state.
1317
- _portRate(port, rx, g) {
1318
- const { n, M, u, uLo, c, nSurf, th } = this, b = this.blockOfNode[g], RT = this.model.RT;
1319
- let pref = rx.k0, a = rx.fixedA;
1320
- for (const p of rx.part) {
1321
- if (p.side === 1) {
1322
- a -= p.nu * this.portEta(port, p.i);
1323
- continue;
1324
- }
1325
- const o = b * M + (p.side === 2 ? 1 + n + p.s : 1 + p.i), e = p.nu < 0 ? -p.nu * (1 - rx.alpha) : p.nu * rx.alpha;
1326
- a -= p.nu * (u[o] + uLo[o]);
1327
- pref *= p.side === 2 ? powr(th[g * nSurf + p.s], e) : powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1328
- }
1329
- if (rx.sites) pref *= powr(this.th0[g], rx.sites);
1330
- return pref * bvFactor(a, rx.alpha).g;
715
+ // Newton's iteration on the equations at dt (src/newton.js).
716
+ newton(dt, opts) {
717
+ return newtonFn(this, dt, opts);
1331
718
  }
1332
719
 
1333
- // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
1334
- // amount-fixed ones (mode 2) at cFix. Fills ζ and c for every present species.
1335
- _materialAt(mat, background, eta, mode, cFix, ph, zeta, cc) {
1336
- const { n, z } = this;
1337
- const RT = this.model.RT;
1338
- for (let i = 0; i < n; i++) {
1339
- if (!mat.present[i]) continue;
1340
- zeta[i] = mode[i] === 2 ? Math.log(cFix[i] / mat.cRef[i]) : eta[i] - mat.mu0[i] / RT - z[i] * ph;
1341
- if (mat.modelOf[i] < 0) cc[i] = mode[i] === 2 ? cFix[i] : mat.cRef[i] * Math.exp(Math.min(700, zeta[i]));
1342
- }
1343
- for (const md of mat.models) {
1344
- const w = this._work(md), idx = md.idx, k = idx.length;
1345
- for (let a = 0; a < k; a++) {
1346
- w.z[a] = zeta[idx[a]];
1347
- w.fixed[a] = mode[idx[a]] === 2 ? 1 : 0;
1348
- w.t[a] = cFix[idx[a]];
1349
- }
1350
- md.invert(w.z, w.fixed, w.t, background);
1351
- md.evaluate(w.z, w.c, w.K, background);
1352
- for (let a = 0; a < k; a++) {
1353
- zeta[idx[a]] = w.z[a];
1354
- cc[idx[a]] = w.c[a];
1355
- }
1356
- }
720
+ /**
721
+ * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
722
+ * The end time is always t + dt; the result reports the substeps taken.
723
+ */
724
+ // A time step, halved until it converges (src/timeSteps.js).
725
+ advance(dt, opts, depth = 0) {
726
+ return advanceFn(this, dt, opts, depth);
1357
727
  }
1358
728
 
1359
- // Each electrode surface's coverages from its η: Langmuir on shared sites, θ_s = e^{ζ_s}/(1 + Σ e^{ζ}),
1360
- // ζ = η − μ°/RT (so θ_s/θ₀ = e^{ζ_s}), at every node of a surfaced window.
1361
- _coverages() {
1362
- const { n, M, u, uLo, nSurf, th } = this, RT = this.model.RT;
1363
- for (let g = 0; g < this.nNodes; g++) {
1364
- const k = this.surfPort[g];
1365
- if (k < 0) continue;
1366
- const surf = this.model.ports[k].surface, b = this.blockOfNode[g];
1367
- let m = 0;
1368
- for (let q = 0; q < surf.length; q++) m = Math.max(m, u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT);
1369
- let sum = Math.exp(-m);
1370
- for (let q = 0; q < surf.length; q++) sum += (th[g * nSurf + q] = Math.exp(u[b * M + 1 + n + q] + uLo[b * M + 1 + n + q] - surf[q].mu0 / RT - m));
1371
- for (let q = 0; q < surf.length; q++) th[g * nSurf + q] /= sum;
1372
- this.th0[g] = Math.exp(-m) / sum;
1373
- }
729
+ // One time step (src/timeSteps.js).
730
+ step(dt, opts = {}) {
731
+ return stepFn(this, dt, opts);
1374
732
  }
1375
733
 
1376
- computeConcentrations() {
1377
- const { n, M, u, uLo, c, z } = this;
1378
- for (let g = 0; g < this.nNodes; g++) {
1379
- const b = this.blockOfNode[g];
1380
- const phiHat = u[b * M];
1381
- if (!this.nodeIdeal[g]) {
1382
- this._nodeStatistics(g, b, phiHat);
1383
- continue;
1384
- }
1385
- const im = this.nodeConductor[g];
1386
- if (im >= 0) {
1387
- // Excess carriers: zero in the bulk, the surface sheet at a charged face.
1388
- for (let i = 0; i < n; i++) c[g * n + i] = 0;
1389
- const f = this.sheetFace[g];
1390
- if (f >= 0) c[g * n + im] = (this.sheetSign[g] * u[this.blockOfFace[f] * M] * this.model.grid.area[g]) / (z[im] * FARADAY * this.model.grid.vol[g]);
1391
- continue;
1392
- }
1393
- for (let i = 0; i < n; i++) {
1394
- const k = g * n + i;
1395
- c[k] = this.present[k] ? this.cRef[k] * Math.exp(u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat) : 0;
1396
- }
1397
- }
1398
- this._coverages();
734
+ // Adaptive time stepping to tEnd (src/timeSteps.js).
735
+ integrate(tEnd, opts = {}) {
736
+ return integrateFn(this, tEnd, opts);
1399
737
  }
1400
738
 
1401
- _work(md) {
1402
- let w = this.scratch.get(md);
1403
- if (!w) {
1404
- const k = md.idx.length;
1405
- w = { z: new Float64Array(k), c: new Float64Array(k), K: new Float64Array(k * k), fixed: new Uint8Array(k), t: new Float64Array(k) };
1406
- this.scratch.set(md, w);
1407
- }
1408
- return w;
739
+ // Probes for a transient's trace: { x, species, quantity: 'c' | 'V', region } read after every
740
+ // accepted step, linearly between the two nodes of x's region around it. At an interface x
741
+ // belongs to both regions, so `region` (a name or index) picks the side; it defaults to the
742
+ // first region that holds x.
743
+ _probes(probes) {
744
+ if (probes === undefined) return null;
745
+ const { grid, regions, species } = this.model;
746
+ const fail = (m) => {
747
+ throw new DeviceError(m);
748
+ };
749
+ if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }, or { interface, species } / { interface, gate }');
750
+ const plan = probes.map((p, k) => {
751
+ const path = `advance: probes[${k}]`;
752
+ if (p?.interface !== undefined) {
753
+ // At a face: a species' flux through it (mol/(m²·s), toward +x), or a gate's fraction open.
754
+ const f = p.interface, itf = this.model.interfaces[f];
755
+ if (!(Number.isInteger(f) && itf)) fail(`${path}.interface: no interface ${JSON.stringify(f)} (they're numbered from 0, left to right)`);
756
+ if (p.gate !== undefined) {
757
+ const q = itf.gates.findIndex((gt) => gt.name === p.gate);
758
+ if (q < 0) fail(`${path}.gate: interfaces[${f}] has no gate ${JSON.stringify(p.gate)}${itf.gates.length ? ` (it has ${itf.gates.map((gt) => gt.name).join(', ')})` : ''}`);
759
+ return { o: this._gateSlot(f, q) };
760
+ }
761
+ const i = species.findIndex((sp) => sp.name === p.species);
762
+ if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p.species)}`);
763
+ return { o: this.blockOfFace[f] * this.M + 1 + i };
764
+ }
765
+ const quantity = p?.quantity ?? 'c';
766
+ if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
767
+ const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
768
+ if (quantity !== 'phi' && i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
769
+ if (quantity === 'V' && species[i].z === 0) fail(`${path}: '${p.species}' is neutral, so it has no voltage; read 'c'`);
770
+ const x = grid.x, inside = (r) => p.x >= x[grid.regionStart[r]] && p.x <= x[grid.regionEnd[r]];
771
+ let r;
772
+ if (p.region !== undefined) {
773
+ r = typeof p.region === 'number' ? p.region : regions.findIndex((reg) => reg.name === p.region);
774
+ if (!(r >= 0 && r < regions.length)) fail(`${path}.region: no region ${JSON.stringify(p.region)}`);
775
+ if (!inside(r)) fail(`${path}.x (${p.x} m) is outside region ${JSON.stringify(p.region)}`);
776
+ } else {
777
+ // At a face, the side where the probe has something to read; both, and it must be told.
778
+ const has = (rr) => {
779
+ const mat = this.model.materials[regions[rr].material];
780
+ return i < 0 ? !mat.conductor && !mat.phiFree : mat.present[i];
781
+ };
782
+ const sides = regions.flatMap((_, rr) => (inside(rr) ? [rr] : []));
783
+ if (!(Number.isFinite(p.x) && sides.length)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
784
+ const able = sides.filter(has);
785
+ if (able.length > 1) fail(`${path}.x (${p.x} m) is on the face between ${able.map((rr) => JSON.stringify(regions[rr].name)).join(' and ')}: give region, the side to read`);
786
+ r = able[0] ?? sides[0];
787
+ }
788
+ if (i >= 0 && !this.model.materials[regions[r].material].present[i]) fail(`${path}: '${p.species}' is absent from region ${JSON.stringify(regions[r].name)}, so there's nothing to read at x = ${p.x} m`);
789
+ let g = grid.regionStart[r];
790
+ while (g + 1 < grid.regionEnd[r] && x[g + 1] < p.x) g++;
791
+ const w = x[g + 1] > x[g] ? (p.x - x[g]) / (x[g + 1] - x[g]) : 0;
792
+ return { i, g, w, quantity };
793
+ });
794
+ const out = plan.map(() => []);
795
+ const { n, M } = this;
796
+ const at = (q, g) => {
797
+ if (q.quantity === 'phi') return this.phiUndefined[g] ? NaN : this.VT * (this.u[this.blockOfNode[g] * M] + this.uLo[this.blockOfNode[g] * M]);
798
+ if (!this.present[g * n + q.i]) return NaN;
799
+ if (q.quantity === 'c') return this.c[g * n + q.i];
800
+ const o = this.blockOfNode[g] * M + 1 + q.i;
801
+ return (this.VT * (this.u[o] + this.uLo[o])) / this.z[q.i];
802
+ };
803
+ return {
804
+ out,
805
+ read: () => plan.map((q) => (q.o !== undefined ? (this.loc[q.o] < 0 ? 0 : this.u[q.o]) : (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1))),
806
+ push: (values) => values.forEach((v, k) => out[k].push(v)),
807
+ };
1409
808
  }
1410
809
 
1411
- // c, K and the excess at a node of a non-ideal material.
1412
- _nodeStatistics(g, b, phiHat) {
1413
- const { n, M, u, uLo, c, z, K, ex, zeta } = this;
1414
- const mat = this.model.materials[this.nodeMaterial[g]];
1415
- const Kg = g * n * n;
1416
- K.fill(0, Kg, Kg + n * n);
1417
- for (let i = 0; i < n; i++) {
1418
- const k = g * n + i;
1419
- ex[k] = 0;
1420
- if (!this.present[k]) {
1421
- c[k] = 0;
1422
- continue;
1423
- }
1424
- zeta[i] = u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat;
1425
- if (mat.modelOf[i] < 0) {
1426
- c[k] = this.cRef[k] * Math.exp(zeta[i]);
1427
- K[Kg + i * n + i] = c[k];
810
+ // Start from another solver's state for the same device on another grid (a refined one): each
811
+ // node's unknowns interpolated linearly in x within its region, each face's copied, and the
812
+ // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
813
+ _warmFrom(src) {
814
+ const { M, u, uLo } = this, grid = this.model.grid, sg = src.model.grid;
815
+ if (src.M !== M || sg.regionStart.length !== grid.regionStart.length) throw new Error('_warmFrom: a different device');
816
+ const value = (b, k) => src.u[b * M + k] + src.uLo[b * M + k];
817
+ for (let r = 0; r < grid.regionStart.length; r++) {
818
+ let a = sg.regionStart[r];
819
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
820
+ const x = grid.x[g];
821
+ while (a + 1 < sg.regionEnd[r] && sg.x[a + 1] < x) a++;
822
+ const b = Math.min(a + 1, sg.regionEnd[r]), h = sg.x[b] - sg.x[a];
823
+ const w = h > 0 ? Math.min(1, Math.max(0, (x - sg.x[a]) / h)) : 0;
824
+ const ba = src.blockOfNode[a], bb = src.blockOfNode[b], bg = this.blockOfNode[g];
825
+ for (let k = 0; k < M; k++) {
826
+ u[bg * M + k] = (1 - w) * value(ba, k) + w * value(bb, k);
827
+ uLo[bg * M + k] = 0;
828
+ }
1428
829
  }
1429
830
  }
1430
- for (const md of mat.models) {
1431
- const w = this._work(md), idx = md.idx, k = idx.length;
1432
- for (let a = 0; a < k; a++) w.z[a] = zeta[idx[a]];
1433
- md.evaluate(w.z, w.c, w.K, this.background[g]);
1434
- for (let a = 0; a < k; a++) {
1435
- const i = idx[a], ci = w.c[a];
1436
- c[g * n + i] = ci;
1437
- ex[g * n + i] = ci > 0 ? zeta[i] - Math.log(ci / this.cRef[g * n + i]) : 0;
1438
- for (let q = 0; q < k; q++) K[Kg + i * n + idx[q]] = w.K[a * k + q];
831
+ for (let f = 0; f < this.nFaces; f++) {
832
+ for (let k = 0; k < M; k++) {
833
+ u[this.blockOfFace[f] * M + k] = value(src.blockOfFace[f], k);
834
+ uLo[this.blockOfFace[f] * M + k] = 0;
1439
835
  }
1440
836
  }
1441
- }
1442
-
1443
- // Σ_j K_ij z_j at node g (= z_i c_i for ideal statistics).
1444
- _Kz(g, i) {
1445
- const { n, z } = this;
1446
- if (this.nodeIdeal[g]) return z[i] * this.c[g * n + i];
1447
- let s = 0;
1448
- const o = g * n * n + i * n;
1449
- for (let j = 0; j < n; j++) s += this.K[o + j] * z[j];
1450
- return s;
1451
- }
1452
-
1453
- /** zᵀKz at node g (per RT): the charge response that sets the screening length. */
1454
- screening(g) {
1455
- let s = 0;
1456
- for (let i = 0; i < this.n; i++) s += this.z[i] * this._Kz(g, i);
1457
- return s;
1458
- }
1459
-
1460
- // Add w·∂(ln c_i)/∂(slot) at node g into d (slot 0 = φ̂, 1 + j = η_j).
1461
- _dlnc(g, i, w, d) {
1462
- const { n, z } = this;
1463
- if (this.nodeIdeal[g]) {
1464
- d[1 + i] += w;
1465
- d[0] -= w * z[i];
1466
- return;
1467
- }
1468
- const ci = this.c[g * n + i];
1469
- if (!(ci > 0)) return;
1470
- const o = g * n * n + i * n, f = w / ci;
1471
- for (let j = 0; j < n; j++) d[1 + j] += f * this.K[o + j];
1472
- d[0] -= f * this._Kz(g, i);
1473
- }
1474
-
1475
- // Add ∂(row rb, slot rs)/∂(block cb, slot cs) to the Jacobian (nothing if either slot isn't
1476
- // an unknown).
1477
- _j(rb, rs, cb, cs, v) {
1478
- const M = this.M, r = this.loc[rb * M + rs], c = this.loc[cb * M + cs];
1479
- if (r < 0 || c < 0) return;
1480
- const sys = this.sys, sz = sys.sizes;
1481
- if (cb === rb) sys.B[sys.offB[rb] + r * sz[rb] + c] += v;
1482
- else if (cb === rb - 1) sys.A[sys.offA[rb] + r * sz[cb] + c] += v;
1483
- else if (cb === rb + 1) sys.C[sys.offC[rb] + r * sz[cb] + c] += v;
1484
- else throw new Error(`internal: non-tridiagonal coupling ${rb}→${cb}`);
1485
- }
1486
-
1487
- /** Assemble residual and Jacobian for a backward-Euler step of size dt. */
1488
- assemble(dt) {
1489
- const { model, res, sys } = this;
1490
- const { grid, materials, regions } = model;
1491
- sys.clear();
1492
- res.fill(0);
1493
- this.dtNow = dt;
1494
- if (this.lin) this.lin.reset(1 / dt);
1495
- if (this.steady && dt === Infinity) snapFlat(this);
1496
- this.computeConcentrations();
1497
-
1498
- // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
1499
- // dilute region (ideal statistics, the fast path) or a concentrated one (any statistics).
1500
- for (let r = 0; r < regions.length; r++) {
1501
- const reg = regions[r], mat = materials[reg.material];
1502
- const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
1503
- if (mat.conductor) {
1504
- for (let g = g0; g <= g1; g++) this._nodeConductor(g, dt);
1505
- for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
1506
- continue;
1507
- }
1508
- if (mat.ideal) this._nodesDilute(g0, g1, dt);
1509
- else for (let g = g0; g <= g1; g++) this._nodeConcentrated(g, dt);
1510
- const rxs = this.rxsIn[reg.material];
1511
- if (rxs.length > 0) for (let g = g0; g <= g1; g++) this._bulkReactions(g, rxs);
1512
- for (let s = g0; s < g1; s++) {
1513
- const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
1514
- this._segmentDisplacement(s, bL, bR, mat, h);
1515
- if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
1516
- if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
1517
- }
1518
- if (mat.ideal) this._segmentsDilute(g0, g1, r, mat, reg.velocity);
1519
- }
1520
-
1521
- // Faces: the flux node carries D, the linked species' fluxes and the reaction rates.
1522
- for (let f = 0; f < this.nFaces; f++) this._face(f);
1523
-
1524
- // Terminals: ports (after every other term at their nodes, so a held level can read its
1525
- // flux), then contacts, then the circuit rows of the floating ones.
1526
- this._terminals(dt);
1527
- // The laws' rows (src/lawRows.js): a time step's circuit laws; a steady system's levels held
1528
- // flat, affinities, conserved amounts, islands' balances, and the pins held aside of them.
1529
- applyLaws(this, dt);
1530
- this.transformed = this.combining && dt !== Infinity;
1531
- if (this.transformed) this._chargeRows(dt);
1532
- }
1533
-
1534
- // A conductor node: only its carrier's balance (the segment flux J has its own row, in
1535
- // _segmentConductor). Its bulk stores nothing; a charged face's sheet sits in the edge node.
1536
- _nodeConductor(g, dt) {
1537
- const { n, M, res, c, cOld, z } = this, R = this.rix;
1538
- const b = this.blockOfNode[g], v = this.model.grid.vol[g], im = this.nodeConductor[g];
1539
- const r = 1 + im, k = g * n + im;
1540
- res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1541
- const f = this.sheetFace[g];
1542
- if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, (this.sheetSign[g] * this.model.grid.area[g]) / (z[im] * FARADAY * dt));
1543
- }
1544
-
1545
- // Whether a port holds species i at node g, a device end node (see portHeldEnd).
1546
- _portHeldAt(g, i) {
1547
- return g === 0 ? this.portHeldEnd[i] === 1 : g === this.nNodes - 1 && this.portHeldEnd[this.n + i] === 1;
1548
- }
1549
-
1550
- // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
1551
- // charge in the Gauss row, written straight into the diagonal blocks by local index. A
1552
- // dielectric is the case with no species.
1553
- _nodesDilute(g0, g1, dt) {
1554
- const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode, lin } = this, R = this.rix, F = FARADAY;
1555
- const JB = sys.B, sizes = sys.sizes, offB = sys.offB, vol = this.model.grid.vol;
1556
- for (let g = g0; g <= g1; g++) {
1557
- const b = blockOfNode[g], v = vol[g];
1558
- const m = sizes[b], oB = offB[b], lb = b * M, p = loc[lb]; // p: φ's row, −1 where undefined
1559
- if (this.combining && this.chargeNode[g] === 1 && dt !== Infinity) {
1560
- // Storage and neutrality go in later, in _chargeRows; a neutral species' storage here.
1561
- for (let i = 0; i < n; i++) if (present[g * n + i] && z[i] === 0) this._storage(g, i, dt);
1562
- continue;
1563
- }
1564
- let q = rhoFixed[g], dq = 0;
1565
- const late = this.combining && this.lateStorage[g] === 1 && dt !== Infinity; // (the ions' storage: in _contact)
1566
- for (let i = 0; i < n; i++) {
1567
- const k = g * n + i;
1568
- if (!present[k]) continue;
1569
- const ck = c[k], l = loc[lb + 1 + i], stores = !(late && z[i] !== 0 && !this._portHeldAt(g, i));
1570
- if (stores) res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
1571
- q += F * z[i] * ck;
1572
- dq += F * z[i] * z[i] * ck;
1573
- if (lin) {
1574
- lin.node(R[lb + 1 + i], R[lb], z[i], stores ? v * ck : 0, -v * F * z[i] * ck);
1575
- continue;
1576
- }
1577
- if (stores) {
1578
- JB[oB + l * m + l] += (v * ck) / dt;
1579
- if (p >= 0 && z[i] !== 0) JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
1580
- }
1581
- if (p >= 0 && z[i] !== 0) JB[oB + p * m + l] += -v * F * z[i] * ck;
1582
- }
1583
- if (p >= 0) {
1584
- res[R[lb]] -= v * q;
1585
- if (!lin) JB[oB + p * m + p] += v * dq;
1586
- }
1587
- }
1588
- }
1589
-
1590
- // A concentrated node (any statistics): ∂c_i/∂η_j = K_ij and ∂c_i/∂φ̂ = −(Kz)_i.
1591
- _nodeConcentrated(g, dt) {
1592
- const { n, M, res, c, z } = this, R = this.rix, F = FARADAY;
1593
- const b = this.blockOfNode[g], v = this.model.grid.vol[g];
1594
- if (this.combining && this.chargeNode[g] === 1 && dt !== Infinity) {
1595
- // Storage and neutrality go in later, in _chargeRows; a neutral species' storage here.
1596
- for (let i = 0; i < n; i++) if (this.present[g * n + i] && z[i] === 0) this._storage(g, i, dt);
1597
- return;
1598
- }
1599
- let q = this.rhoFixed[g], dq = 0;
1600
- const lin = this.lin;
1601
- for (let i = 0; i < n; i++) {
1602
- const k = g * n + i, r = 1 + i;
1603
- if (!this.present[k]) continue;
1604
- const Kz = this._Kz(g, i);
1605
- if (lin) {
1606
- // In difference form (see DifferenceTerms): storage of i and its charge, in each
1607
- // δη_j − z_j δφ̂ through K_ij.
1608
- res[R[b * M + r]] += (v * (c[k] - this.cOld[k])) / dt;
1609
- q += F * z[i] * c[k];
1610
- const Kg = g * n * n + i * n;
1611
- for (let j = 0; j < n; j++) {
1612
- const Kij = this.nodeIdeal[g] ? (i === j ? c[k] : 0) : this.K[Kg + j];
1613
- if (Kij !== 0) lin.node(R[b * M + 1 + j], R[b * M], z[j], v * Kij, -v * F * z[i] * Kij, R[b * M + r]);
1614
- }
1615
- continue;
1616
- }
1617
- this._storage(g, i, dt, Kz);
1618
- q += F * z[i] * c[k];
1619
- dq += F * z[i] * Kz;
1620
- if (Kz !== 0) this._j(b, 0, b, r, -v * F * Kz); // K symmetric: ∂(Σ z c)/∂η_i = (Kz)_i
1621
- }
1622
- // (where no charge responds and no field reaches, φ isn't defined, nor an unknown)
1623
- if (!this.phiUndefined[g]) {
1624
- res[R[b * M]] -= v * q;
1625
- if (!lin) this._j(b, 0, b, 0, v * dq);
1626
- }
1627
- }
1628
-
1629
- // Species i's storage at node g, v(c − c_old)/dt, in its balance row and the Jacobian
1630
- // (∂c_i/∂η_j = K_ij, ∂c_i/∂φ̂ = −(Kz)_i; for ideal statistics K is diagonal, c_i).
1631
- _storage(g, i, dt, Kz = this._Kz(g, i)) {
1632
- const { n, M, res, c, cOld } = this, R = this.rix;
1633
- const b = this.blockOfNode[g], v = this.model.grid.vol[g], k = g * n + i, r = 1 + i;
1634
- res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1635
- if (this.nodeIdeal[g]) this._j(b, r, b, r, (v * c[k]) / dt);
1636
- else {
1637
- const Kg = g * n * n;
1638
- for (let j = 0; j < n; j++) {
1639
- const Kij = this.K[Kg + i * n + j];
1640
- if (Kij !== 0) this._j(b, r, b, 1 + j, (v * Kij) / dt);
1641
- }
1642
- }
1643
- if (Kz !== 0 && !this.phiUndefined[g]) this._j(b, r, b, 0, (-v * Kz) / dt);
1644
- }
1645
-
1646
- // Strictly neutral nodes on a transient step, solved in better-conditioned terms. There, a
1647
- // change of φ̂ with every η_i shifted by z_i times it leaves every concentration as it was, so
1648
- // storage and neutrality don't see it; but in (φ̂, η) they see it as pairs of huge entries that
1649
- // cancel, and round-off in that cancellation (storage/flux ~ h²/(D·dt), times the range of
1650
- // concentrations: a trace ion beside 3 M KCl) swamps the fluxes that do fix it. So, at each node
1651
- // that stays neutral (see chargeNode), with storage and neutrality left out of assembly:
1652
- // - rows: the balance of the most abundant charged species (by z²c) becomes Σ (z_i/z_k) × each
1653
- // balance, which without storage is current continuity;
1654
- // - columns: the unknowns become φ̂' and η'_i = η_i − z_i φ̂ (newton() maps the update back),
1655
- // in which storage and neutrality have no φ̂' term at all;
1656
- // - then storage (on the other balances) and neutrality go in, exactly, in those terms.
1657
- // A start-of-step net charge (round-off in a solved state) isn't carried over: the neutrality
1658
- // row holds the new state neutral.
1659
- _chargeRows(dt) {
1660
- const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY, live = this._liveTerms();
1661
- const { A, B, C, sizes, offA, offB, offC } = sys, vol = this.model.grid.vol, last = this.nB - 1;
1662
- for (const g of this.chargeNodes) {
1663
- const b = this.blockOfNode[g], lb = b * M, m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < last ? sizes[b + 1] : 0;
1664
- const p = loc[lb], gn = g * n;
1665
- let k = -1, best = -1;
1666
- for (let i = 0; i < n; i++) {
1667
- if (!present[gn + i] || z[i] === 0) continue;
1668
- const w = z[i] * z[i] * c[gn + i];
1669
- if (w > best) {
1670
- k = i;
1671
- best = w;
1672
- }
1673
- }
1674
- if (k < 0 || p < 0) continue;
1675
- // Rows: the pivot's balance becomes the z-weighted sum (all still without storage).
1676
- const lk = loc[lb + 1 + k], rk = R[lb + 1 + k];
1677
- for (let i = 0; i < n; i++) {
1678
- if (i === k || !present[gn + i] || z[i] === 0) continue;
1679
- const li = loc[lb + 1 + i], w = z[i] / z[k], ri = R[lb + 1 + i];
1680
- for (let q = 0, o = offA[b] + lk * mp, s = offA[b] + li * mp; q < mp; q++) A[o + q] += w * A[s + q];
1681
- for (let q = 0, o = offB[b] + lk * m, s = offB[b] + li * m; q < m; q++) B[o + q] += w * B[s + q];
1682
- for (let q = 0, o = offC[b] + lk * mn, s = offC[b] + li * mn; q < mn; q++) C[o + q] += w * C[s + q];
1683
- res[rk] += w * res[ri];
1684
- for (const t of live) termB[t][rk] += w * termB[t][ri];
1685
- if (this.lin) this.lin.combine(rk, ri, w);
1686
- }
1687
- // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
1688
- // unknowns appear (its own rows, its neighbours', a terminal's current).
1689
- for (let i = 0; i < n; i++) {
1690
- if (!present[gn + i] || z[i] === 0) continue;
1691
- const li = loc[lb + 1 + i], zi = z[i];
1692
- for (let r = 0, o = offB[b]; r < m; r++) B[o + r * m + p] += zi * B[o + r * m + li];
1693
- if (b > 0) for (let r = 0, mr = mp, o = offC[b - 1]; r < mr; r++) C[o + r * m + p] += zi * C[o + r * m + li];
1694
- if (b < last) for (let r = 0, mr = mn, o = offA[b + 1]; r < mr; r++) A[o + r * m + p] += zi * A[o + r * m + li];
1695
- for (const t of live) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1696
- }
1697
- // Storage on the other charged balances, and neutrality: no φ̂' terms, exactly.
1698
- const v = vol[g], oB = offB[b];
1699
- let q = this.rhoFixed[g];
1700
- if (this.nodeIdeal[g]) {
1701
- for (let i = 0; i < n; i++) {
1702
- if (!present[gn + i] || z[i] === 0) continue;
1703
- const ci = c[gn + i], li = loc[lb + 1 + i];
1704
- if (i !== k) {
1705
- res[R[lb + 1 + i]] += (v * (ci - cOld[gn + i])) / dt;
1706
- B[oB + li * m + li] += (v * ci) / dt;
1707
- }
1708
- q += F * z[i] * ci;
1709
- B[oB + p * m + li] += -v * F * z[i] * ci;
1710
- }
1711
- } else {
1712
- for (let i = 0; i < n; i++) {
1713
- if (!present[gn + i]) continue;
1714
- if (i !== k && z[i] !== 0) this._storage(g, i, dt, 0);
1715
- q += F * z[i] * c[gn + i];
1716
- const Kz = this._Kz(g, i); // (nonzero for a neutral species only on a shared lattice)
1717
- if (Kz !== 0) this._j(b, 0, b, 1 + i, -v * F * Kz);
1718
- }
1719
- }
1720
- res[R[lb]] -= v * q;
1721
- // The pivot's row (charge continuity) left storage out, the ions' net charge being constant
1722
- // under neutrality. Against a capacitance it isn't: neutrality makes it −(aσ + ρ_fixed)/F,
1723
- // so their storage is that less what the step started with, Σ z c_old, over dt (in units
1724
- // of the pivot's balance).
1725
- if (this.qgTerms[g].length > 0) {
1726
- const f = -v / (FARADAY * z[k] * dt);
1727
- let qOld = this.rhoFixed[g];
1728
- for (let i = 0; i < n; i++) if (present[gn + i]) qOld += F * z[i] * cOld[gn + i];
1729
- res[rk] += f * (this.qg[g] + qOld);
1730
- B[offB[b] + lk * m + p] += -f * this.qgSlope[g] * this.VT;
1731
- for (const [kq, w] of this.qgTerms[g]) termB[kq][rk] += f * this.portArea[kq - 2][w] * this.model.ports[kq - 2].capacitance.C;
1732
- }
1733
- }
1734
- }
1735
-
1736
- // Newton's update in the transformed unknowns (see _chargeRows) back in η: η_i = η'_i + z_i φ̂'.
1737
- _untransform(delta) {
1738
- const { n, M, z, loc, present } = this, R = this.rix;
1739
- for (const g of this.chargeNodes) {
1740
- const lb = this.blockOfNode[g] * M;
1741
- if (loc[lb] < 0) continue;
1742
- const dphi = delta[R[lb]];
1743
- for (let i = 0; i < n; i++) if (present[g * n + i] && z[i] !== 0) delta[R[lb + 1 + i]] += z[i] * dphi;
1744
- }
1745
- }
1746
-
1747
- // Bulk reactions at node g: r = k_f Π c_R^ν · (−expm1(−a)), with a = A/RT from the
1748
- // (compensated) η. Reactants are consumed (+v·ν·r in their balance), products made (−v·ν·r).
1749
- _bulkReactions(g, rxs) {
1750
- const { n, M, u, uLo, res, c } = this, R = this.rix;
1751
- const b = this.blockOfNode[g], v = this.model.grid.vol[g];
1752
- const dr = this.dA;
1753
- for (let x = 0; x < rxs.length; x++) {
1754
- const rx = rxs[x];
1755
- const law = rx.srh?.[this.nodeMaterial[g]];
1756
- if (law) {
1757
- // SRH: dr is ∂rate/∂slot directly, from its sensitivities to ln n, ln p and a.
1758
- let aHi = rx.fixedA, aLo = 0;
1759
- for (let p = 0; p < rx.sp.length; p++) {
1760
- aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
1761
- aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
1762
- }
1763
- const { rate, Cn, Cp, Ca } = this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo, ...this._srhDegen(g, g, law));
1764
- dr.fill(0);
1765
- this._dlnc(g, law.n.i, Cn, dr);
1766
- this._dlnc(g, law.p.i, Cp, dr);
1767
- for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Ca * rx.nu[p];
1768
- for (let p = 0; p < rx.sp.length; p++) {
1769
- const row = 1 + rx.sp[p], w = v * rx.nu[p];
1770
- res[R[b * M + row]] += w * rate;
1771
- for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
1772
- }
1773
- continue;
1774
- }
1775
- let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
1776
- dr.fill(0); // ∂ ln P / ∂slot
1777
- // Participants: reactants with ν > 0, then products with ν < 0.
1778
- for (let p = 0; p < rx.sp.length; p++) {
1779
- const i = rx.sp[p], nu = rx.nu[p];
1780
- if (nu > 0) {
1781
- const ci = c[g * n + i];
1782
- P *= nu === 1 ? ci : powi(ci, nu);
1783
- this._dlnc(g, i, nu, dr);
1784
- }
1785
- aHi += nu * u[b * M + 1 + i];
1786
- aLo += nu * uLo[b * M + 1 + i];
1787
- }
1788
- const a = aHi + aLo;
1789
- const f = -Math.expm1(-a); // 1 − e^{−a}
1790
- const rate = P * f;
1791
- const Pd = P * (1 - f); // P·df/da, df/da = e^{−a}
1792
- // ∂rate/∂slot = rate·∂lnP + P·f′·∂a
1793
- for (let k = 0; k < M; k++) dr[k] *= rate;
1794
- for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Pd * rx.nu[p];
1795
- for (let p = 0; p < rx.sp.length; p++) {
1796
- const row = 1 + rx.sp[p], w = v * rx.nu[p];
1797
- res[R[b * M + row]] += w * rate;
1798
- for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
1799
- }
1800
- }
1801
- }
1802
-
1803
- // Displacement along a segment where φ is defined: D = −ε(φ_R − φ_L)/h into the two Gauss rows.
1804
- _segmentDisplacement(s, bL, bR, mat, h) {
1805
- const { M, u, res, sys, loc } = this, R = this.rix;
1806
- const k = this.phiUndefined[s] ? 0 : (mat.epsr * EPS0 * this.VT) / h; // ε = 0: no displacement
1807
- if (k === 0) return;
1808
- const D = -k * (u[bR * M] - u[bL * M]);
1809
- const mL = sys.sizes[bL], mR = sys.sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1810
- res[R[bL * M]] += D;
1811
- res[R[bR * M]] -= D;
1812
- if (this.lin) return this.lin.displacement(R[bL * M], R[bR * M], k);
1813
- sys.B[sys.offB[bL] + pL * mL + pL] += k;
1814
- sys.C[sys.offC[bL] + pL * mR + pR] -= k;
1815
- sys.A[sys.offA[bR] + pR * mL + pL] -= k;
1816
- sys.B[sys.offB[bR] + pR * mR + pR] += k;
1817
- }
1818
-
1819
- // Scharfetter–Gummel fluxes along a dilute region's segments (nodes g0…g1), written
1820
- // by local index. The flux
1821
- // N = g[B(Δ)c_L − B(−Δ)c_R] is rewritten with B(−Δ) = B(Δ)e^Δ and c_R e^Δ = c_L e^{Δη} as
1822
- // N = −g·B(Δ)·c_L·expm1(Δη). This is precise relative to the quasi-Fermi difference Δη, so tiny
1823
- // fluxes (e.g. majority carriers carrying a small current) don't vanish in the cancellation of
1824
- // two huge drift and diffusion terms.
1825
- _segmentsDilute(g0, g1, region, mat, vel) {
1826
- const { n, M, u, uLo, res, c, z, sys, loc, lin } = this, R = this.rix;
1827
- const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1828
- const { segLength, segArea } = this.model.grid;
1829
- for (let s = g0; s < g1; s++) {
1830
- const bL = s + region, bR = bL + 1, h = segLength[s] / segArea[s]; // (fluxes are totals through the cross-section)
1831
- const mL = sizes[bL], mR = sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1832
- const phiL = u[bL * M], phiR = u[bR * M];
1833
- for (let i = 0; i < n; i++) {
1834
- if (!mat.present[i] || mat.D[i] === 0) continue;
1835
- const r = 1 + i, zi = z[i];
1836
- const cL = c[s * n + i];
1837
- const g = mat.D[i] / h;
1838
- const pe = (vel * h) / mat.D[i]; // advection: a Péclet shift of the drift potential
1839
- const d = zi * (phiR - phiL) - pe;
1840
- const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
1841
- const E = Math.expm1(deta);
1842
- const gBc = g * bernoulli(d) * cL;
1843
- const N = -gBc * E;
1844
- const dNdd = -g * bernoulliDerivative(d) * cL * E;
1845
- const dNdEtaL = gBc;
1846
- const dNdEtaR = -gBc * (E + 1);
1847
- const dNdPhiL = -zi * dNdd + zi * gBc * E; // via Δ, and via c_L ∝ e^{−zφ̂_L}
1848
- const dNdPhiR = zi * dNdd;
1849
- res[R[bL * M + r]] += N;
1850
- res[R[bR * M + r]] -= N;
1851
- if (lin) {
1852
- lin.segment(R[bL * M + r], R[bR * M + r], R[bL * M], R[bR * M], zi, -gBc * E, dNdEtaR, zi * dNdd);
1853
- continue;
1854
- }
1855
- // Row r of block bL (couplings to itself in B, to bR in C) and of bR (to bL in A).
1856
- const lL = loc[bL * M + r], lR = loc[bR * M + r];
1857
- const oBL = offB[bL] + lL * mL, oCL = offC[bL] + lL * mR, oAR = offA[bR] + lR * mL, oBR = offB[bR] + lR * mR;
1858
- JB[oBL + lL] += dNdEtaL;
1859
- JC[oCL + lR] += dNdEtaR;
1860
- JA[oAR + lL] -= dNdEtaL;
1861
- JB[oBR + lR] -= dNdEtaR;
1862
- if (zi !== 0 && pL >= 0) {
1863
- JB[oBL + pL] += dNdPhiL;
1864
- JC[oCL + pR] += dNdPhiR;
1865
- JA[oAR + pL] -= dNdPhiL;
1866
- JB[oBR + pR] -= dNdPhiR;
1867
- }
1868
- }
1869
- }
1870
- }
1871
-
1872
- // A face: its flux node's rows are the interface laws (φ law, species links, reactions), and
1873
- // its fluxes enter the two edge nodes' balances.
1874
- _face(f) {
1875
- const { model, n, M, u, uLo, res, z, VT } = this, R = this.rix, F = FARADAY;
1876
- const grid = model.grid, interfaces = model.interfaces;
1877
- const bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
1878
- const itf = interfaces[f];
1879
- // φ law: pinned jump (dipole), Helmholtz capacitor, or no charge at all (neutral).
1880
- const law = itf.phi.type;
1881
- const condSide = itf.conductor && law !== 'neutral' ? itf.conductor.side : null;
1882
- if (condSide) {
1883
- // Against a metal: the other side's φ is tied to the metal's Fermi level V_F = V_T η/z.
1884
- const im = itf.conductor.i, bm = condSide === 'left' ? bL : bR, bo = condSide === 'left' ? bR : bL;
1885
- const sg = condSide === 'left' ? 1 : -1;
1886
- const vf = (u[bm * M + 1 + im] + uLo[bm * M + 1 + im]) / z[im]; // V_F / V_T
1887
- const gap = vf - itf.zeroCharge / VT - (u[bo * M] + uLo[bo * M]); // (V_F − zeroCharge − φ_edge)/V_T
1888
- if (law === 'pinned') {
1889
- res[R[bf * M]] = gap;
1890
- this._j(bf, 0, bm, 1 + im, 1 / z[im]);
1891
- this._j(bf, 0, bo, 0, -1);
1892
- } else {
1893
- // D toward the other side = C (V_F − zeroCharge − φ_edge); toward +x that's sg times it.
1894
- const kC = itf.phi.C * VT;
1895
- res[R[bf * M]] = u[bf * M] - sg * kC * gap;
1896
- this._j(bf, 0, bf, 0, 1);
1897
- this._j(bf, 0, bm, 1 + im, (-sg * kC) / z[im]);
1898
- this._j(bf, 0, bo, 0, sg * kC);
1899
- }
1900
- } else if (law === 'neutral') {
1901
- // D = 0 (not an unknown); the jump is whatever each side's neutrality needs
1902
- } else {
1903
- const jump = u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) - itf.dipole / VT;
1904
- if (law === 'pinned') {
1905
- res[R[bf * M]] = jump;
1906
- this._j(bf, 0, bR, 0, 1);
1907
- this._j(bf, 0, bL, 0, -1);
1908
- } else {
1909
- // D = −C (φ_R − φ_L − dipole): displacement toward +x drops across the layer.
1910
- const kC = itf.phi.C * VT;
1911
- res[R[bf * M]] = u[bf * M] + kC * jump;
1912
- this._j(bf, 0, bf, 0, 1);
1913
- this._j(bf, 0, bR, 0, kC);
1914
- this._j(bf, 0, bL, 0, -kC);
1915
- }
1916
- }
1917
- // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead. The
1918
- // flux node's unknowns are per area, so what they carry is that times the face's area.
1919
- const gL = grid.regionEnd[f], gR = gL + 1, Af = grid.area[gL];
1920
- if (this.nodeConductor[gL] < 0) {
1921
- res[R[bL * M]] += Af * u[bf * M];
1922
- this._j(bL, 0, bf, 0, Af);
1923
- }
1924
- if (this.nodeConductor[gR] < 0) {
1925
- res[R[bR * M]] -= Af * (u[bf * M] + itf.sheetCharge);
1926
- this._j(bR, 0, bf, 0, -Af);
1927
- }
1928
- for (let i = 0; i < n; i++) {
1929
- const r = 1 + i, o = bf * M + r;
1930
- const type = itf.links[i].type;
1931
- const deta = u[bL * M + r] - u[bR * M + r] + (uLo[bL * M + r] - uLo[bR * M + r]); // η_L − η_R
1932
- if (type === 'blocked') continue;
1933
- if (type === 'equilibrium') {
1934
- res[R[o]] = -deta; // μ̄ continuous
1935
- this._j(bf, r, bR, r, 1);
1936
- this._j(bf, r, bL, r, -1);
1937
- } else if (type === 'permeability') {
1938
- // Electrodiffusion through a thin membrane in a constant field: Scharfetter–Gummel across
1939
- // the face with D/h → P, its drift taken over the face's whole jump in the standard level
1940
- // zφ̂ + μ°/RT − ln c_ref (the φ jump of a capacitive face, and any step between the two
1941
- // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
1942
- // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
1943
- // and it's exactly zero where μ̄ is level.
1944
- const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], link = itf.links[i];
1945
- const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
1946
- const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
1947
- const E = Math.expm1(-deta); // η_R − η_L
1948
- // Gated: P × Π x^p, each x taken in [0, 1] (Newton's iterates can stray past its ends).
1949
- const gated = link.gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
1950
- const P = link.P * (gated.length ? this._open(gated, gx) : 1);
1951
- const gBc = P * bernoulli(d) * this.c[kl];
1952
- const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
1953
- res[R[o]] = u[o] + gBc * E;
1954
- this._j(bf, r, bf, r, 1);
1955
- for (const [q, p] of gated) {
1956
- const dO = this._dOpen(gated, q, p, gx);
1957
- if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, link.P * bernoulli(d) * this.c[kl] * E * dO);
1958
- }
1959
- this._j(bf, r, bL, r, -gBc);
1960
- this._j(bf, r, bR, r, gBc * (E + 1));
1961
- if (zi !== 0) {
1962
- this._j(bf, r, bL, 0, zi * dNdd - zi * gBc * E);
1963
- this._j(bf, r, bR, 0, -zi * dNdd);
1964
- }
1965
- } else {
1966
- // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F); gated,
1967
- // G times Π x^p
1968
- const gated = itf.links[i].gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
1969
- const gG0 = (itf.links[i].G * VT) / (z[i] * z[i] * F), gG = gG0 * (gated.length ? this._open(gated, gx) : 1);
1970
- res[R[o]] = u[o] - gG * deta;
1971
- this._j(bf, r, bf, r, 1);
1972
- this._j(bf, r, bL, r, -gG);
1973
- this._j(bf, r, bR, r, gG);
1974
- for (const [q, p] of gated) {
1975
- const dO = this._dOpen(gated, q, p, gx);
1976
- if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, -gG0 * deta * dO);
1977
- }
1978
- }
1979
- res[R[bL * M + r]] += Af * u[o];
1980
- this._j(bL, r, bf, r, Af);
1981
- res[R[bR * M + r]] -= Af * u[o];
1982
- this._j(bR, r, bf, r, -Af);
1983
- }
1984
- itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
1985
- if (itf.gates.length > 0) this._faceGates(f, bf, bL, bR);
1986
- }
1987
-
1988
- // Full index of face f's gate q (its fraction open).
1989
- _gateSlot(f, q) {
1990
- return this.blockOfFace[f] * this.M + 1 + this.n + this.model.interfaces[f].reactions.length + q;
1991
- }
1992
-
1993
- // The voltage a gate follows: across its face, or across its port's capacitance at its node.
1994
- _gateVoltage(gt) {
1995
- return gt.port === undefined ? this._faceVoltage(gt.f) : this._portVoltage(gt.port, gt.g);
1996
- }
1997
-
1998
- // The voltage across port k's capacitance at node g, φ − (V − zeroCharge) (V): a membrane's,
1999
- // inside (the region) minus outside.
2000
- _portVoltage(k, g) {
2001
- const { M, u, uLo, VT } = this, b = this.blockOfNode[g], port = this.model.ports[k];
2002
- return VT * (u[b * M] + uLo[b * M]) - (this.termV[2 + k] - port.capacitance.zeroCharge);
2003
- }
2004
-
2005
- // The voltage across face f, φ_right − φ_left (V).
2006
- _faceVoltage(f) {
2007
- const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
2008
- return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]));
2009
- }
2010
-
2011
- // Face f's gates, each a row (x − x_old)/dt = α(1 − x) − βx in the voltage across the face.
2012
- _faceGates(f, bf, bL, bR) {
2013
- const { res, VT } = this, R = this.rix, itf = this.model.interfaces[f], dt = this.dtNow;
2014
- const V = this._faceVoltage(f), k0 = 1 + this.n + itf.reactions.length;
2015
- itf.gates.forEach((gate, q) => {
2016
- const o = this._gateSlot(f, q), x = this.u[o];
2017
- const [a, da] = gateRate(gate.alpha, V), [b, db] = gateRate(gate.beta, V);
2018
- const store = Number.isFinite(dt) ? 1 / dt : 0, j = this.gateIndex[f] + q;
2019
- res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - b * x);
2020
- this._j(bf, k0 + q, bf, k0 + q, store + a + b);
2021
- const dV = -(da * (1 - x) - db * x) * VT; // per unit φ̂_R, and minus that per unit φ̂_L
2022
- this._j(bf, k0 + q, bR, 0, dV);
2023
- this._j(bf, k0 + q, bL, 0, -dV);
2024
- });
2025
- }
2026
-
2027
- // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
2028
- // extra potential, linear along the segment like φ, so Δ = zΔφ̂ + Δex and
2029
- // N = −(D/h)·B(Δ)·c_L·expm1(η_R − η_L),
2030
- // still exactly zero at equilibrium. c_L and ex depend on every ζ at their node through K.
2031
- _segmentConcentrated(s, bL, bR, mat, h, vel) {
2032
- const R = this.rix;
2033
- const { n, M, res, z, K } = this;
2034
- const KL = s * n * n, KR = (s + 1) * n * n;
2035
- for (let i = 0; i < n; i++) {
2036
- if (!mat.present[i] || mat.D[i] === 0) continue;
2037
- const r = 1 + i, zi = z[i];
2038
- const { N, E, gBc, dNdd, fL, cR } = this._fluxConcentrated(s, bL, bR, mat, h, vel, i);
2039
- res[R[bL * M + r]] += N;
2040
- res[R[bR * M + r]] -= N;
2041
- if (this.lin) {
2042
- // In difference form (see DifferenceTerms): b on Δη_i and z_i·∂N/∂d on Δφ̂, and the rest
2043
- // as side terms in each δη_j − z_j δφ̂ at either end (c_L and the excess potentials).
2044
- const rL = R[bL * M + r], rR = R[bR * M + r], pL = R[bL * M], pR = R[bR * M];
2045
- this.lin.segment(rL, rR, pL, pR, zi, 0, -gBc * (E + 1), zi * dNdd);
2046
- for (let j = 0; j < n; j++) {
2047
- const wL = fL * K[KL + i * n + j] - (j === i ? dNdd : 0);
2048
- const wR = cR > 0 ? dNdd * ((j === i ? 1 : 0) - K[KR + i * n + j] / cR) : 0;
2049
- if (wL !== 0) this.lin.side(rL, rR, R[bL * M + 1 + j], pL, z[j], wL);
2050
- if (wR !== 0) this.lin.side(rL, rR, R[bR * M + 1 + j], pR, z[j], wR);
2051
- }
2052
- continue;
2053
- }
2054
- const { jL, jR } = this;
2055
- for (let k = 0; k < M; k++) {
2056
- if (jL[k] !== 0) {
2057
- this._j(bL, r, bL, k, jL[k]);
2058
- this._j(bR, r, bL, k, -jL[k]);
2059
- }
2060
- if (jR[k] !== 0) {
2061
- this._j(bL, r, bR, k, jR[k]);
2062
- this._j(bR, r, bR, k, -jR[k]);
2063
- }
2064
- }
2065
- }
2066
- }
2067
-
2068
- // Species i's flux across segment s of a non-ideal region, toward +x, and its derivatives in
2069
- // this.jL and this.jR (over the unknowns of the blocks bL and bR).
2070
- _fluxConcentrated(s, bL, bR, mat, h, vel, i) {
2071
- const { n, M, u, uLo, c, z, K, ex, jL, jR } = this;
2072
- const gL = s, gR = s + 1, KL = gL * n * n, KR = gR * n * n, r = 1 + i, zi = z[i];
2073
- const cL = c[gL * n + i], cR = c[gR * n + i];
2074
- const g = mat.D[i] / h;
2075
- const pe = (vel * h) / mat.D[i];
2076
- const d = zi * (u[bR * M] - u[bL * M]) + ex[gR * n + i] - ex[gL * n + i] - pe;
2077
- const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
2078
- const E = Math.expm1(deta);
2079
- const B = bernoulli(d), Bp = bernoulliDerivative(d);
2080
- const gBc = g * B * cL;
2081
- const N = -gBc * E;
2082
- const dNdd = -g * Bp * cL * E;
2083
- // Left node: ∂N/∂ζ_Lj = −gE(B + B′)K_L,ij − dNdd·δ_ij, plus the direct η_L,i term.
2084
- const fL = -g * E * (B + Bp);
2085
- jL[0] = -fL * this._Kz(gL, i);
2086
- for (let j = 0; j < n; j++) jL[1 + j] = fL * K[KL + i * n + j];
2087
- jL[r] += -dNdd + gBc * (E + 1);
2088
- // Right node: ∂N/∂ζ_Rj = dNdd·(δ_ij − K_R,ij/c_R), plus the direct η_R,i term.
2089
- jR.fill(0);
2090
- if (cR > 0) {
2091
- const fR = -dNdd / cR;
2092
- jR[0] = -fR * this._Kz(gR, i);
2093
- for (let j = 0; j < n; j++) jR[1 + j] = fR * K[KR + i * n + j];
2094
- } else {
2095
- jR[0] = dNdd * zi; // K/c → δ as c → 0
2096
- jR[r] -= dNdd;
2097
- }
2098
- jR[r] += dNdd - gBc * (E + 1);
2099
- return { N, E, gBc, dNdd, fL, cR };
2100
- }
2101
-
2102
- // Ohmic conduction in a metal, in mixed form. The flux J (slot 0 of the left node) obeys
2103
- // η_R − η_L + J/g = 0, g = σ RT/(z²F² h) (Ohm's law, J = −g Δη),
2104
- // and enters the two nodes' carrier balances as outflow and inflow.
2105
- _segmentConductor(s, bL, bR, mat, h) {
2106
- const R = this.rix;
2107
- const { M, u, uLo, res, z } = this;
2108
- const i = mat.conductor.i, r = 1 + i;
2109
- const g = (mat.conductor.sigma * this.model.RT) / (z[i] * z[i] * FARADAY * FARADAY * h);
2110
- const J = u[bL * M];
2111
- res[R[bL * M]] = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) + J / g;
2112
- this._j(bL, 0, bR, r, 1);
2113
- this._j(bL, 0, bL, r, -1);
2114
- this._j(bL, 0, bL, 0, 1 / g);
2115
- res[R[bL * M + r]] += J;
2116
- res[R[bR * M + r]] -= J;
2117
- this._j(bL, r, bL, 0, 1);
2118
- this._j(bR, r, bL, 0, -1);
2119
- }
2120
-
2121
- // Eddy mixing: N = −(D_mix/RT) P ∇μ̄ with P = C − (Cz)(Cz)ᵀ/(zᵀCz), C = diag(c). It mixes
2122
- // composition without carrying current (zᵀP = 0) and vanishes exactly at equilibrium. On a
2123
- // segment, N_i = −(D_mix/h) Σ_j P̄_ij Δη_j, with P̄ from the logarithmic mean of each c, so a
2124
- // neutral species gets exactly −D_mix Δc/h. Only mobile species (D > 0) take part.
2125
- _segmentMixing(s, bL, bR, Dm, h, mat) {
2126
- const R = this.rix;
2127
- const { n, M, u, uLo, res, c, z } = this;
2128
- const gL = s, gR = s + 1, k0 = Dm / h;
2129
- const on = mat.mobile ?? (mat.mobile = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0));
2130
- if (on.length === 0) return;
2131
- const w = this.mixWork ?? (this.mixWork = [0, 1, 2, 3].map(() => new Float64Array(n)));
2132
- const [cb, dLa, dLb, de] = w;
2133
- let S = 0, Q = 0;
2134
- for (const k of on) {
2135
- const a = c[gL * n + k], b = c[gR * n + k];
2136
- // l = ln(c_R/c_L), from the compensated potentials where statistics are ideal
2137
- const l = this.nodeIdeal[gL]
2138
- ? u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]) - z[k] * (u[bR * M] - u[bL * M])
2139
- : Math.log(b / a);
2140
- // L = a·f(l), f = expm1(l)/l; ∂L/∂a = f − f′, ∂L/∂b = a f′/b
2141
- let f, fp;
2142
- if (Math.abs(l) < 1e-3) {
2143
- f = 1 + l / 2 + (l * l) / 6 + (l * l * l) / 24;
2144
- fp = 0.5 + l / 3 + (l * l) / 8 + (l * l * l) / 30;
2145
- } else {
2146
- const em = Math.expm1(l);
2147
- f = em / l;
2148
- fp = (l * (em + 1) - em) / (l * l);
2149
- }
2150
- cb[k] = a * f;
2151
- dLa[k] = f - fp;
2152
- dLb[k] = b > 0 ? (a * fp) / b : 0.5;
2153
- de[k] = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]);
2154
- S += z[k] * z[k] * cb[k];
2155
- Q += z[k] * cb[k] * de[k];
2156
- }
2157
- const q = S > 0 ? Q / S : 0;
2158
- const { jL, jR } = this;
2159
- const dcL = this.dA, dcR = this.dB; // ∂c_k/∂slot at each end
2160
- for (const i of on) {
2161
- const r = 1 + i;
2162
- const N = -k0 * cb[i] * (de[i] - z[i] * q);
2163
- jL.fill(0);
2164
- jR.fill(0);
2165
- for (const j of on) {
2166
- // direct dependence on Δη_j
2167
- const P = cb[i] * ((i === j ? 1 : 0) - (S > 0 ? (z[i] * z[j] * cb[j]) / S : 0));
2168
- jL[1 + j] += k0 * P;
2169
- jR[1 + j] -= k0 * P;
2170
- }
2171
- // through c̄_k: ∂a_i/∂c̄_k = δ_ik (Δη_i − z_i q) − c̄_i z_i (z_k Δη_k − q z_k²)/S
2172
- for (const k of on) {
2173
- let da = (i === k ? de[i] - z[i] * q : 0) - (S > 0 ? (cb[i] * z[i] * (z[k] * de[k] - q * z[k] * z[k])) / S : 0);
2174
- if (da === 0) continue;
2175
- da *= -k0;
2176
- this._dc(gL, k, dcL);
2177
- this._dc(gR, k, dcR);
2178
- for (let t = 0; t < M; t++) {
2179
- jL[t] += da * dLa[k] * dcL[t];
2180
- jR[t] += da * dLb[k] * dcR[t];
2181
- }
2182
- }
2183
- res[R[bL * M + r]] += N;
2184
- res[R[bR * M + r]] -= N;
2185
- for (let t = 0; t < M; t++) {
2186
- if (jL[t] !== 0) {
2187
- this._j(bL, r, bL, t, jL[t]);
2188
- this._j(bR, r, bL, t, -jL[t]);
2189
- }
2190
- if (jR[t] !== 0) {
2191
- this._j(bL, r, bR, t, jR[t]);
2192
- this._j(bR, r, bR, t, -jR[t]);
2193
- }
2194
- }
2195
- }
2196
- }
2197
-
2198
- // ∂c_k/∂slot at node g into d (slot 0 = φ̂, 1 + j = η_j).
2199
- _dc(g, k, d) {
2200
- const { n } = this;
2201
- d.fill(0);
2202
- if (this.nodeIdeal[g]) {
2203
- const ck = this.c[g * n + k];
2204
- d[1 + k] = ck;
2205
- d[0] = -this.z[k] * ck;
2206
- return;
2207
- }
2208
- const o = g * n * n + k * n;
2209
- for (let j = 0; j < n; j++) d[1 + j] = this.K[o + j];
2210
- d[0] = -this._Kz(g, k);
2211
- }
2212
-
2213
- // A bulk reaction's rate at node g (mol/(m³·s), forward), as assembled: for the solution.
2214
- bulkRate(rx, g) {
2215
- const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
2216
- let aHi = rx.fixedA, aLo = 0;
2217
- for (let p = 0; p < rx.sp.length; p++) {
2218
- aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
2219
- aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
2220
- }
2221
- const law = rx.srh?.[this.nodeMaterial[g]];
2222
- if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo, ...this._srhDegen(g, g, law)).rate;
2223
- let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2224
- for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2225
- return P * -Math.expm1(-(aHi + aLo));
2226
- }
2227
-
2228
- // A bulk reaction's forward one-way rate at node g (mol/(m³·s)); its backward one is that
2229
- // times e^{−a}, so their difference is bulkRate's.
2230
- bulkForward(rx, g) {
2231
- const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
2232
- const law = rx.srh?.[this.nodeMaterial[g]];
2233
- if (law) {
2234
- let a = rx.fixedA;
2235
- for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
2236
- return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a, ...this._srhDegen(g, g, law)).forward;
2237
- }
2238
- let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2239
- for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2240
- return P;
2241
- }
2242
-
2243
- // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
2244
- // rate is read against (in equilibrium the net rate is their round-off).
2245
- bulkOneWay(rx, g) {
2246
- const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
2247
- let a = rx.fixedA;
2248
- for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
2249
- const law = rx.srh?.[this.nodeMaterial[g]];
2250
- if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a, ...this._srhDegen(g, g, law)).oneWay;
2251
- let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2252
- for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2253
- return P * Math.max(1, Math.exp(-a));
2254
- }
2255
-
2256
- // SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
2257
- // a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}).
2258
- // SRH at electron and hole concentrations cn, cp and affinity a. n₁p₁ = n p e^{−a} (n_i² for
2259
- // Boltzmann carriers), and under degeneracy each carrier's emission scales with its own
2260
- // γ = e^{−ex} (its excess chemical potential ex, c/K = ∂ln c/∂ζ its slope): n₁ = γ_n n₁₀ and
2261
- // p₁ = γ_p p₁₀, n₁₀ the law's n1, or n_i at midgap. (γ_n γ_p split evenly onto both, as it was,
2262
- // put a degenerate n⁺ region's γ_n on its holes' emission too.) The rate's derivatives in ln n,
2263
- // ln p and a. For large negative a (strong extraction), np·(1 − e^{−a}) as −n p e^{−a}.
2264
- _srhRate(law, cn, cp, a, xn = 0, xp = 0, sn = 1, sp = 1) {
2265
- const { tn, tp } = law, lnnp = Math.log(cn) + Math.log(cp);
2266
- let lnn1, kN = 0, kP = 0, kA = 0; // ln n₁ = kN ln n + kP ln p + kA a + const
2267
- if (Number.isNaN(law.n1)) {
2268
- lnn1 = (lnnp - a - xn + xp) / 2; // γ_n n_i, with n_i² = n p e^{−a}/(γ_n γ_p)
2269
- kN = (2 - sn) / 2;
2270
- kP = sp / 2;
2271
- kA = -0.5;
2272
- } else {
2273
- lnn1 = Math.log(law.n1) - xn;
2274
- kN = 1 - sn;
2275
- }
2276
- const n1 = Math.exp(lnn1), p1 = Math.exp(lnnp - a - lnn1), np = cn * cp;
2277
- const den = tp * (cn + n1) + tn * (cp + p1);
2278
- // np(1 − e^{−a}), with e^{−a} folded into np where it would overflow
2279
- const f = -Math.expm1(-a), num = a > -700 ? np * f : np - Math.exp(lnnp - a);
2280
- const rate = num / den;
2281
- const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
2282
- const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
2283
- const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
2284
- return { rate, forward: np / den, oneWay: a > -700 ? (np / den) * Math.max(1, Math.exp(-a)) : Math.exp(lnnp - a) / den, Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (a > -700 ? (np / den) * (1 - f) : Math.exp(lnnp - a) / den) - (rate * dDa) / den };
2285
- }
2286
-
2287
- // _srhRate's degeneracy arguments: the electrons' at node gn, the holes' at gq.
2288
- _srhDegen(gn, gq, law) {
2289
- const [xn, sn] = this._degen(gn, law.n.i), [xp, sp] = this._degen(gq, law.p.i);
2290
- return [xn, xp, sn, sp];
2291
- }
2292
-
2293
- // A carrier's excess chemical potential at node g and ∂ln c/∂ζ there (0 and 1 if ideal).
2294
- _degen(g, i) {
2295
- if (this.nodeIdeal[g]) return [0, 1];
2296
- const n = this.n, c = this.c[g * n + i], K = this.K[g * n * n + i * n + i];
2297
- return [this.ex[g * n + i], c > 0 && K > 0 ? c / K : 1];
2298
- }
2299
-
2300
- // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
2301
- // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
2302
- // there when ν > 0). The rate couples the two edge nodes only through the face block between
2303
- // them, which keeps the system block-tridiagonal.
2304
- _faceReaction(rx, k, f, bf, bL, bR, Af) {
2305
- const { n, M, u, uLo, c, res } = this;
2306
- const R = this.rix;
2307
- const grid = this.model.grid;
2308
- const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
2309
- const al = rx.alpha;
2310
- const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
2311
- let pref = rx.k0, aHi = rx.fixedA, aLo = 0, rate;
2312
- for (const p of rx.part) {
2313
- const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
2314
- aHi -= p.nu * u[o];
2315
- aLo -= p.nu * uLo[o];
2316
- if (rx.srh || rx.vmax || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
2317
- const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
2318
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2319
- this._dlnc(g, p.i, e, p.side ? dR : dL);
2320
- }
2321
- if (rx.vmax) {
2322
- // Saturating: r = vmax Π (c/(c + K))^{|ν|} (1 − e^{−a}); ∂ln S/∂ln c = |ν| K/(c + K).
2323
- let S = 1;
2324
- rx.part.forEach((p, q) => {
2325
- if (p.nu >= 0) return;
2326
- const g = p.side ? gR : gL, cc = c[g * n + p.i], K = rx.K[q];
2327
- S *= powi(cc / (cc + K), -p.nu);
2328
- this._dlnc(g, p.i, (-p.nu * K) / (cc + K), p.side ? dR : dL);
2329
- });
2330
- const a = aHi + aLo, back = Math.exp(-a);
2331
- rate = rx.vmax * S * -Math.expm1(-a);
2332
- for (let s = 0; s < M; s++) {
2333
- dL[s] *= rate;
2334
- dR[s] *= rate;
2335
- }
2336
- for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= rx.vmax * S * back * p.nu;
2337
- } else if (rx.srh) {
2338
- // SRH, with n and p each at its own side's edge node.
2339
- const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
2340
- const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo, ...this._srhDegen(gn, gq, { n: pn, p: pp }));
2341
- rate = s.rate;
2342
- this._dlnc(gn, pn.i, s.Cn, pn.side ? dR : dL);
2343
- this._dlnc(gq, pp.i, s.Cp, pp.side ? dR : dL);
2344
- for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= s.Ca * p.nu;
2345
- } else {
2346
- const { g: bv, gp } = bvFactor(aHi + aLo, al);
2347
- rate = pref * bv;
2348
- // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
2349
- for (let s = 0; s < M; s++) {
2350
- dL[s] *= rate;
2351
- dR[s] *= rate;
2352
- }
2353
- for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
2354
- }
2355
- const slot = 1 + n + k, o = bf * M + slot;
2356
- res[R[o]] = u[o] - rate;
2357
- this._j(bf, slot, bf, slot, 1);
2358
- for (let s = 0; s < M; s++) {
2359
- if (dL[s] !== 0) this._j(bf, slot, bL, s, -dL[s]);
2360
- if (dR[s] !== 0) this._j(bf, slot, bR, s, -dR[s]);
2361
- }
2362
- for (const p of rx.part) {
2363
- const b = p.side ? bR : bL;
2364
- res[R[b * M + 1 + p.i]] -= Af * p.nu * u[o];
2365
- this._j(b, 1 + p.i, bf, slot, -Af * p.nu);
2366
- }
2367
- }
2368
-
2369
- // The terminals whose ∂res/∂V (termB) and ∂I/∂x (termC) are kept, exactly: the floating ones,
2370
- // which the Newton solve borders on, or all of them (allTerminals: for the impedance, which reads
2371
- // its terminal's). A held terminal's are otherwise left as they fall (written but not kept
2372
- // consistent), which spares a device with dozens of ports (an axon's nodes of Ranvier) the cost
2373
- // of carrying each one's two dense vectors through every assembly.
2374
- _liveTerms() {
2375
- if (this.allTerminals) return this._allTerms ?? (this._allTerms = this.terms.map((_, k) => k));
2376
- return this.floating;
2377
- }
2378
-
2379
- // Every terminal's terms: its voltage held or floating, how the residual depends on it
2380
- // (termB), its current into the device (termI) and how that depends on the state (termC).
2381
- _terminals(dt) {
2382
- this._refreshSources();
2383
- for (const k of this._liveTerms()) {
2384
- this.termB[k].fill(0);
2385
- this.termC[k].fill(0);
2386
- }
2387
- this.termDI.fill(0);
2388
- for (const x of this.termDX) x.fill(0);
2389
- this.termI.fill(0);
2390
- this.qg.fill(0);
2391
- this.qgSlope.fill(0);
2392
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
2393
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, true));
2394
- this._contact('left', dt);
2395
- this._contact('right', dt);
2396
- for (const k of this.floating) this._circuit(k);
2397
- }
2398
-
2399
- // A floating terminal's circuit law: I − I_set = 0 (driven by a current), or I − (V_src − V)/R = 0
2400
- // (a source behind a resistance).
2401
- _circuit(k) {
2402
- const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
2403
- if (d.kind === 'I') this.termRes[k] = this.termI[k] - src;
2404
- else {
2405
- this.termRes[k] = this.termI[k] - (src - this.termV[k]) / d.R;
2406
- this.termDI[k] += 1 / d.R;
2407
- }
2408
- }
2409
-
2410
- // into += w × (the Jacobian row of block b, slot s), over compact columns.
2411
- _captureRow(b, s, w, into) {
2412
- const l = this.loc[b * this.M + s];
2413
- if (l < 0 || w === 0) return;
2414
- if (this.lin) this.lin.capture(this.rix[b * this.M + s], w, this.lin.rate, into);
2415
- const { sys, nB } = this, { sizes, offA, offB, offC, offX } = sys, m = sizes[b];
2416
- for (const [X, nb, off] of [['A', b - 1, offA], ['B', b, offB], ['C', b + 1, offC]]) {
2417
- if (nb < 0 || nb >= nB) continue;
2418
- const mc = sizes[nb], o = off[b] + l * mc, arr = sys[X], c0 = offX[nb];
2419
- for (let c = 0; c < mc; c++) if (arr[o + c] !== 0) into[c0 + c] += w * arr[o + c];
2420
- }
2421
- return m;
2422
- }
2423
-
2424
- // An internal port (terminal k): its exchange with each node of its window, as a source per
2425
- // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
2426
- // row's residual, read just before. Its current into the device is Σ z F × the sources. In two
2427
- // passes over the ports: everything else, then the held levels (`holds`), so that a level reads
2428
- // every other port's terms at its nodes, and none is added into its row after.
2429
- _port(port, flux, k, dt, holds) {
2430
- const R = this.rix;
2431
- const { n, M, u, uLo, res, z, VT } = this;
2432
- const F = FARADAY, vol = this.model.grid.vol;
2433
- const B = this.termB[k], C = this.termC[k];
2434
- if (!holds) flux.fill(0);
2435
- for (let i = 0; i < n; i++) {
2436
- const link = port.species[i];
2437
- if (link.type === 'blocked' || (link.type === 'equilibrium') !== holds) continue;
2438
- const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
2439
- for (const g of port.nodes) {
2440
- const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
2441
- const deta = target - u[o] - uLo[o]; // (μ̄_out − μ̄)/RT
2442
- if (link.type === 'equilibrium') {
2443
- // a device end node's level is its contact's business, where the contact links it
2444
- if ((g === 0 && this.model.contacts.left.species[i].type !== 'blocked') || (g === this.nNodes - 1 && this.model.contacts.right.species[i].type !== 'blocked')) continue;
2445
- flux[i] += res[R[o]];
2446
- this._captureRow(b, r, zF, C);
2447
- this._replaceRow(b, r);
2448
- for (const Bk of this.termB) Bk[R[o]] = 0; // (other ports' terms in the row replaced)
2449
- this._j(b, r, b, r, 1);
2450
- res[R[o]] = -deta;
2451
- B[R[o]] += -z[i] / VT;
2452
- continue;
2453
- }
2454
- // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η). Gated, G times
2455
- // Π x^p, the gates at this node (each taken in [0, 1]).
2456
- const kk0 = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2457
- const gated = link.gates ?? [], q0 = 1 + n + port.surface.length;
2458
- const open = gated.length ? this._open(gated, (q) => u[b * M + q0 + q]) : 1, kk = kk0 * open;
2459
- res[R[o]] -= v * kk * deta;
2460
- this._j(b, r, b, r, v * kk);
2461
- flux[i] += v * kk * deta;
2462
- if (z[i] !== 0) {
2463
- B[R[o]] += (-v * kk * z[i]) / VT;
2464
- C[R[o]] += -zF * v * kk;
2465
- this.termDI[k] += (zF * v * kk * z[i]) / VT;
2466
- }
2467
- for (const [q, p] of gated) {
2468
- const d = this._dOpen(gated, q, p, (qq) => u[b * M + q0 + qq]);
2469
- if (d === 0) continue;
2470
- this._j(b, r, b, q0 + q, -v * kk0 * deta * d);
2471
- if (z[i] !== 0) C[R[b * M + q0 + q]] += zF * v * kk0 * deta * d;
2472
- }
2473
- }
2474
- }
2475
- if (holds) {
2476
- for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2477
- return;
2478
- }
2479
- port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2480
- if (port.capacitance) this._portCapacitance(port, k, dt);
2481
- if (port.gates.length > 0) this._portGates(port, k, dt);
2482
- if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2483
- }
2484
-
2485
- // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
2486
- // the electrode's carrier at the port's level (activity 1), times the electrode's area per
2487
- // volume. Each node's rate is explicit (no unknown of its own): it enters the balances of the
2488
- // species it makes and consumes, and the port's current.
2489
- _portReaction(port, rx, area, rates, flux, k) {
2490
- const { n, M, u, uLo, c, res, z, VT, nSurf, th } = this, R = this.rix, F = FARADAY, vol = this.model.grid.vol, RT = this.model.RT;
2491
- const B = this.termB[k], C = this.termC[k], al = rx.alpha, d = this.dA, surf = port.surface;
2492
- // The carrier's part of the affinity, and how it moves with the port's voltage.
2493
- let aV = rx.fixedA, daV = 0, q = 0; // q: charge the forward reaction brings into the device, per event
2494
- for (const p of rx.part) {
2495
- if (p.side === 1) {
2496
- aV -= p.nu * this.portEta(port, p.i);
2497
- daV -= (p.nu * z[p.i]) / VT;
2498
- } else if (p.side === 0) q += p.nu * z[p.i];
2499
- }
2500
- const slot = (p) => (p.side === 2 ? 1 + n + p.s : 1 + p.i);
2501
- port.nodes.forEach((g, w) => {
2502
- const b = this.blockOfNode[g], s = vol[g] * area[w];
2503
- d.fill(0);
2504
- let pref = rx.k0, aHi = aV, aLo = 0;
2505
- for (const p of rx.part) {
2506
- if (p.side === 1) continue;
2507
- const o = b * M + slot(p);
2508
- aHi -= p.nu * u[o];
2509
- aLo -= p.nu * uLo[o];
2510
- const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
2511
- if (p.side === 2) {
2512
- // a surface species at its coverage θ = θ₀e^ζ, with ∂ln θ_s/∂η_x = δ_sx − θ_x
2513
- pref *= powr(th[g * nSurf + p.s], e);
2514
- d[1 + n + p.s] += e;
2515
- for (let x = 0; x < surf.length; x++) d[1 + n + x] -= e * th[g * nSurf + x];
2516
- } else {
2517
- pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
2518
- this._dlnc(g, p.i, e, d);
2519
- }
2520
- }
2521
- if (rx.sites) {
2522
- // the bare sites its transition state holds: θ₀ = 1 − Σθ, with ∂ln θ₀/∂η_s = −θ_s
2523
- for (let x = 0; x < surf.length; x++) d[1 + n + x] -= rx.sites * th[g * nSurf + x];
2524
- pref *= powr(this.th0[g], rx.sites);
2525
- }
2526
- const { g: bv, gp } = bvFactor(aHi + aLo, al);
2527
- const rate = pref * bv;
2528
- rates[w] = rate;
2529
- // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η = −ν
2530
- for (let t = 0; t < M; t++) d[t] *= rate;
2531
- for (const p of rx.part) if (p.side !== 1) d[slot(p)] -= pref * gp * p.nu;
2532
- const dV = pref * gp * daV;
2533
- for (const p of rx.part) {
2534
- if (p.side === 1) continue;
2535
- // the region's species per volume of the window; a surface's per area of electrode
2536
- const row = slot(p), o = b * M + row, f = p.side === 2 ? 1 : s;
2537
- res[R[o]] -= f * p.nu * rate;
2538
- if (p.side === 0) flux[p.i] += s * p.nu * rate;
2539
- for (let t = 0; t < M; t++) if (d[t] !== 0) this._j(b, row, b, t, -f * p.nu * d[t]);
2540
- B[R[o]] -= f * p.nu * dV;
2541
- }
2542
- for (let t = 0; t < M; t++) if (d[t] !== 0) C[R[b * M + t]] += q * F * s * d[t];
2543
- this.termDI[k] += q * F * s * dV;
2544
- });
2545
- }
2546
-
2547
- // A capacitance spread through a port's window: per area of electrode, σ = C (V − zeroCharge − φ)
2548
- // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2549
- // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2550
- // contact sets is left to the contact.
2551
- // A membrane port's gates, at each node of its window: (x − x_old)/dt = α(1 − x) − βx in the
2552
- // voltage across its capacitance there, φ − (V − zeroCharge).
2553
- _portGates(port, k, dt) {
2554
- const { n, res, VT } = this, R = this.rix, B = this.termB[k], store = Number.isFinite(dt) ? 1 / dt : 0, q0 = 1 + n + port.surface.length;
2555
- port.nodes.forEach((g, w) => {
2556
- const b = this.blockOfNode[g], V = this._portVoltage(k - 2, g);
2557
- port.gates.forEach((gate, q) => {
2558
- const o = b * this.M + q0 + q, x = this.u[o], j = this.portGateIndex[k - 2] + w * port.gates.length + q;
2559
- const [a, da] = gateRate(gate.alpha, V), [bb, db] = gateRate(gate.beta, V);
2560
- res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - bb * x);
2561
- this._j(b, q0 + q, b, q0 + q, store + a + bb);
2562
- const s = -(da * (1 - x) - db * x); // ∂/∂V_m, with V_m = V_T φ̂ − V + zeroCharge
2563
- this._j(b, q0 + q, b, 0, s * VT);
2564
- B[R[o]] += -s;
2565
- });
2566
- });
2567
- }
2568
-
2569
- // Π x^p over a link's gates (each x taken in [0, 1]), and its derivative in gate q.
2570
- _open(gated, x) {
2571
- let open = 1;
2572
- for (const [q, p] of gated) open *= powi(Math.min(1, Math.max(0, x(q))), p);
2573
- return open;
2574
- }
2575
- _dOpen(gated, q, p, x) {
2576
- const xq = x(q);
2577
- if (!(xq > 0 && xq < 1)) return 0;
2578
- let others = 1;
2579
- for (const [q2, p2] of gated) if (q2 !== q) others *= powi(Math.min(1, Math.max(0, x(q2))), p2);
2580
- return p * powi(xq, p - 1) * others;
2581
- }
2582
-
2583
- _portCapacitance(port, k, dt) {
2584
- const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2585
- const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2586
- const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
2587
- // Its current, the charging (Q − Q at the step's start)/dt; or, steady at no current, the
2588
- // charge it keeps in place of that (see captureLaws in src/lawRows.js).
2589
- const rate = Number.isFinite(dt) ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
2590
- let Q = 0, dQdV = 0;
2591
- port.nodes.forEach((g, w) => {
2592
- if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
2593
- const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
2594
- const q = s * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2595
- this.qg[g] += area[w] * Cs * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2596
- this.qgSlope[g] += area[w] * Cs;
2597
- Q += q;
2598
- dQdV += s;
2599
- res[R[b * M]] -= q;
2600
- this._j(b, 0, b, 0, s * VT);
2601
- B[R[b * M]] -= s;
2602
- if (rate > 0) Ct[R[b * M]] += -s * VT * rate;
2603
- });
2604
- this.portQ[k - 2] = Q;
2605
- if (rate > 0) {
2606
- this.termI[k] += (Q - (dt < Infinity ? this.portQStart[k - 2] : this.chargeRefs.get(k))) * rate;
2607
- this.termDI[k] += dQdV * rate;
2608
- }
2609
- }
2610
-
2611
- // An electrode surface's storage, per area of electrode: Γ (θ_s − θ_s,old)/dt in each surface
2612
- // species' row, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
2613
- _surfaceStorage(port, dt) {
2614
- const { n, M, res, nSurf, th, thOld } = this, R = this.rix, surf = port.surface, G = surf[0].capacity;
2615
- for (const g of port.nodes) {
2616
- const b = this.blockOfNode[g];
2617
- // Near full coverage θ − θ_old cancels (both ~1, the change ~θ₀): the dominant species'
2618
- // change comes from the small ones instead, Δθ = −Δθ₀ − Σ_others Δθ.
2619
- let top = 0;
2620
- for (let q = 1; q < surf.length; q++) if (th[g * nSurf + q] > th[g * nSurf + top]) top = q;
2621
- for (let q = 0; q < surf.length; q++) {
2622
- const tq = th[g * nSurf + q];
2623
- let change = tq - thOld[g * nSurf + q];
2624
- if (q === top && tq > 0.5) {
2625
- change = -(this.th0[g] - this.th0Old[g]);
2626
- for (let x = 0; x < surf.length; x++) if (x !== q) change -= th[g * nSurf + x] - thOld[g * nSurf + x];
2627
- }
2628
- res[R[b * M + 1 + n + q]] += (G * change) / dt;
2629
- for (let x = 0; x < surf.length; x++) this._j(b, 1 + n + q, b, 1 + n + x, ((G * tq) / dt) * ((q === x ? 1 : 0) - th[g * nSurf + x]));
2630
- }
2631
- }
2632
- }
2633
-
2634
- // One contact (terminal 0 or 1): record the flux through its outer face and the current into
2635
- // the device (read from the end box before anything here is added: whatever the box needs
2636
- // comes through the contact), then add its exchange terms, its equilibrium links and its φ law.
2637
- _contact(side, dt) {
2638
- const R = this.rix;
2639
- const { model, n, M, u, uLo, res, c, z, VT } = this;
2640
- const F = FARADAY;
2641
- const ct = model.contacts[side];
2642
- const k = side === 'left' ? 0 : 1, Vt = this.termV[k];
2643
- const B = this.termB[k], C = this.termC[k];
2644
- const g = side === 'left' ? 0 : this.nNodes - 1;
2645
- const b = this.blockOfNode[g];
2646
- const sgn = side === 'left' ? 1 : -1;
2647
- const flux = this.contactFlux[side];
2648
- const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
2649
- const dyn = Number.isFinite(dt);
2650
-
2651
- // Before anything is added, each balance residual is the flux into the device here. (A
2652
- // closed end solved in charge rows has its ions' storage left out until later: none of them
2653
- // passes, so their flux is zero.)
2654
- const closedIons = this.combining && dyn && this.chargeNode[g] === 1;
2655
- let I = 0;
2656
- const held = this.portHeldEnd.subarray(k * n, (k + 1) * n);
2657
- for (let i = 0; i < n; i++) {
2658
- const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0) && !held[i];
2659
- const nIn = active ? res[R[b * M + 1 + i]] : 0;
2660
- flux[i] = sgn * nIn; // (toward +x)
2661
- if (active && z[i] !== 0) {
2662
- I += z[i] * F * nIn;
2663
- this._captureRow(b, 1 + i, z[i] * F, C);
2664
- }
2665
- }
2666
- // The ions' storage, left out until now (see lateStorage): in each balance, and in what
2667
- // each species brings in, but not in the current.
2668
- if (this.combining && dyn && this.lateStorage[g] === 1) {
2669
- const v = model.grid.vol[g], { cOld } = this;
2670
- for (let i = 0; i < n; i++) {
2671
- if (z[i] === 0 || this.loc[b * M + 1 + i] < 0 || held[i]) continue;
2672
- const ci = c[g * n + i], st = (v * (ci - cOld[g * n + i])) / dt;
2673
- res[R[b * M + 1 + i]] += st;
2674
- flux[i] += sgn * st;
2675
- this._j(b, 1 + i, b, 1 + i, (v * ci) / dt);
2676
- this._j(b, 1 + i, b, 0, (-v * z[i] * ci) / dt);
2677
- }
2678
- }
2679
-
2680
- // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2681
- // links (neutral species): N_in = k (μ_out − μ)/RT; velocity links, v (c_eq − c).
2682
- for (let i = 0; i < n; i++) {
2683
- const link = ct.species[i];
2684
- const o = b * M + 1 + i;
2685
- if (link.type === 'exchange') {
2686
- res[R[o]] -= Ac * link.k * (link.mu / model.RT - u[o] - uLo[o]);
2687
- this._j(b, 1 + i, b, 1 + i, Ac * link.k);
2688
- } else if (link.type === 'conductance') {
2689
- const Vi = (VT * (u[o] + uLo[o])) / z[i];
2690
- res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
2691
- this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
2692
- B[R[o]] += (-Ac * link.G) / (z[i] * F);
2693
- } else if (link.type === 'velocity') {
2694
- // N_in = v c (e^(η_out − η) − 1): v (c_eq − c), c_eq in equilibrium with the outside.
2695
- const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - u[o] - uLo[o], 700));
2696
- const N = Ac * link.v * ci * (E - 1), d = this.dA;
2697
- res[R[o]] -= N;
2698
- d.fill(0);
2699
- this._dlnc(g, i, 1, d);
2700
- for (let s = 0; s <= n; s++) if (d[s] !== 0) this._j(b, 1 + i, b, s, -N * d[s]);
2701
- this._j(b, 1 + i, b, 1 + i, Ac * link.v * ci * E);
2702
- if (z[i] !== 0) B[R[o]] += (-Ac * link.v * ci * E * z[i]) / VT;
2703
- }
2704
- }
2705
-
2706
- // Equilibrium links: Dirichlet on the known outside level.
2707
- for (let i = 0; i < n; i++) {
2708
- const link = ct.species[i];
2709
- if (link.type !== 'equilibrium') continue;
2710
- const o = b * M + 1 + i;
2711
- this._replaceRow(b, 1 + i);
2712
- this._j(b, 1 + i, b, 1 + i, 1);
2713
- res[R[o]] = u[o] - this.contactEta(side, i) + uLo[o]; // (the big terms first: u + uLo rounds to u)
2714
- if (z[i] !== 0) B[R[o]] += -z[i] / VT;
2715
- }
2716
-
2717
- // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
2718
- // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
2719
- const link = ct.phi, Dstart = sgn * this.contactDStart[side];
2720
- // (In a steady solve, what a law in this circuit row counts of D_in: see applyConstraints in
2721
- // src/lawRows.js.)
2722
- const q = !dyn && this.steady && this.contactLaw[k] ? (this.contactQ[k] ??= { row: new Float64Array(this.sys.size + 1), D: 0, dV: 0 }) : null;
2723
- if (q) {
2724
- q.row.fill(0);
2725
- q.D = 0;
2726
- q.dV = 0;
2727
- }
2728
- if (this.phiUndefined[g]) {
2729
- this.contactD[side] = 0;
2730
- } else if (link.type === 'capacitive') {
2731
- // Gate, or metal across a Stern layer, at φ_g = V_t − zeroCharge. D toward +x.
2732
- const Din = Ac * link.C * (Vt - link.zeroCharge - VT * u[b * M]);
2733
- res[R[b * M]] -= Din;
2734
- this._j(b, 0, b, 0, Ac * link.C * VT);
2735
- B[R[b * M]] += -Ac * link.C;
2736
- this.contactD[side] = sgn * Din;
2737
- if (q && this.loc[b * M] >= 0) [q.D, q.row[R[b * M]], q.dV] = [Din, -Ac * link.C * VT, Ac * link.C];
2738
- // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
2739
- // the charge it keeps in place of that (see captureLaws in src/lawRows.js).
2740
- const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
2741
- if (rate > 0) {
2742
- I += (Din - (dyn ? Dstart : this.chargeRefs.get(k))) * rate;
2743
- C[R[b * M]] += -Ac * link.C * VT * rate;
2744
- this.termDI[k] += Ac * link.C * rate;
2745
- }
2746
- } else if (link.type === 'pinned' || link.type === 'bulk') {
2747
- // The Poisson residual is the outside's charge, D_in.
2748
- const Din = res[R[b * M]];
2749
- this.contactD[side] = sgn * Din;
2750
- if (q) {
2751
- q.D = Din;
2752
- this._captureRow(b, 0, 1, q.row);
2753
- }
2754
- if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2755
- I += (Din - Dstart) / dt;
2756
- this._captureRow(b, 0, 1 / dt, C);
2757
- } else if (!dyn && this.steady && this.chargeRefs.has(k)) {
2758
- I += Din - this.chargeRefs.get(k);
2759
- this._captureRow(b, 0, 1, C);
2760
- }
2761
- this._replaceRow(b, 0);
2762
- if (link.type === 'pinned') {
2763
- // φ_edge = V_t − zeroCharge (the C → ∞ limit).
2764
- this._j(b, 0, b, 0, 1);
2765
- res[R[b * M]] = u[b * M] - (Vt - link.zeroCharge) / VT + uLo[b * M];
2766
- B[R[b * M]] += -1 / VT;
2767
- } else {
2768
- // Plain bulk: local neutrality.
2769
- let q = this.rhoFixed[g], dq = 0;
2770
- for (let i = 0; i < n; i++) {
2771
- const kk = g * n + i;
2772
- if (!this.present[kk]) continue;
2773
- const Kz = this._Kz(g, i);
2774
- q += F * z[i] * c[kk];
2775
- dq -= F * z[i] * Kz;
2776
- if (Kz !== 0) this._j(b, 0, b, 1 + i, F * Kz);
2777
- }
2778
- this._j(b, 0, b, 0, dq);
2779
- res[R[b * M]] = q;
2780
- }
2781
- } else {
2782
- this.contactD[side] = 0;
2783
- }
2784
- this.termI[k] = I;
2785
- }
2786
-
2787
-
2788
-
2789
-
2790
- // Zero one row of the Jacobian (all three blocks), ready to be replaced.
2791
- _replaceRow(b, rs) {
2792
- const l = this.loc[b * this.M + rs];
2793
- if (l < 0) return;
2794
- if (this.lin) this.lin.kill(this.rix[b * this.M + rs]);
2795
- const { sys, nB } = this, sz = sys.sizes, m = sz[b];
2796
- sys.B.fill(0, sys.offB[b] + l * m, sys.offB[b] + (l + 1) * m);
2797
- if (b > 0) sys.A.fill(0, sys.offA[b] + l * sz[b - 1], sys.offA[b] + (l + 1) * sz[b - 1]);
2798
- if (b < nB - 1) sys.C.fill(0, sys.offC[b] + l * sz[b + 1], sys.offC[b] + (l + 1) * sz[b + 1]);
2799
- }
2800
-
2801
- // Scale every row by its largest Jacobian entry (in place, residual too), dividing by it: its
2802
- // reciprocal overflows where it's subnormal (a co-ion crowded out of a double layer, ~1e-310).
2803
- _equilibrate() {
2804
- const { nB, sys, res, termB } = this, live = this._liveTerms();
2805
- const { A, B, C, sizes, offA, offB, offC, offX } = sys;
2806
- for (let b = 0; b < nB; b++) {
2807
- const m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < nB - 1 ? sizes[b + 1] : 0;
2808
- for (let r = 0; r < m; r++) {
2809
- const oa = offA[b] + r * mp, ob = offB[b] + r * m, oc = offC[b] + r * mn;
2810
- let mx = 0;
2811
- for (let k = 0; k < mp; k++) mx = Math.max(mx, Math.abs(A[oa + k]));
2812
- for (let k = 0; k < m; k++) mx = Math.max(mx, Math.abs(B[ob + k]));
2813
- for (let k = 0; k < mn; k++) mx = Math.max(mx, Math.abs(C[oc + k]));
2814
- if (mx === 0) {
2815
- this.rowDiv[offX[b] + r] = 1;
2816
- continue;
2817
- }
2818
- const d = mx;
2819
- this.rowDiv[offX[b] + r] = d;
2820
- for (let k = 0; k < mp; k++) A[oa + k] /= d;
2821
- for (let k = 0; k < m; k++) B[ob + k] /= d;
2822
- for (let k = 0; k < mn; k++) C[oc + k] /= d;
2823
- res[offX[b] + r] /= d;
2824
- for (const k of live) termB[k][offX[b] + r] /= d; // (each terminal's ∂/∂V)
2825
- }
2826
- }
2827
- }
2828
-
2829
- // The Jacobian at dt with the dilute kernels' terms kept aside in difference form (see
2830
- // DifferenceTerms), the rest assembled into a matrix of its own. The residual and terminal terms
2831
- // come out as from assemble(dt); the Newton matrix is left as it was.
2832
- _assembleDifference(dt) {
2833
- const keep = this.sys;
2834
- const rest = this.sysRest ?? (this.sysRest = new BlockTridiagonal(keep.n, keep.sizes));
2835
- const n = this.n, nN = this.nNodes;
2836
- const lin = this.diffTerms ?? (this.diffTerms = new DifferenceTerms(keep.size, nN * n * n, nN * n, 2 * nN * n * n));
2837
- this.sys = rest;
2838
- this.lin = lin;
2839
- try {
2840
- this.assemble(dt);
2841
- } finally {
2842
- this.sys = keep;
2843
- this.lin = null;
2844
- }
2845
- return { rest, lin };
2846
- }
2847
-
2848
- // A Newton solve refined by GMRES: the factorised Jacobian as the preconditioner, and J·x
2849
- // exact, with the dilute kernels' terms in difference form (see DifferenceTerms), as the
2850
- // impedance does. The assembled J holds a flux's dependence on η at its two ends as two
2851
- // entries; where they're huge (an inversion layer's 0.1 nm cells, a conductance ~1e11 against
2852
- // the layer's own storage and the trickle from the bulk), eliminating them loses the layer's
2853
- // overall level to round-off, and Newton rattles there. delta and deltaV come in as the plain
2854
- // solve's and leave refined (in the transformed unknowns, as solveBordered gives them); false
2855
- // if refining didn't help, leaving them as they came.
2856
- _refine(dt, delta, deltaV) {
2857
- const { res, termRes, termB, termC, termDI, rowDiv, floating: fl } = this;
2858
- const N = this.sys.size, K = fl.length, pins = this.pins, R = this.rix;
2859
- const save = { res: Float64Array.from(res), termRes: Float64Array.from(termRes), termB: termB.map((b) => Float64Array.from(b)), termC: termC.map((c) => Float64Array.from(c)), termDI: Float64Array.from(termDI), termDX: this.termDX.map((x) => Float64Array.from(x)), transformed: this.transformed, pinRes: this.pins.map((p) => p.res) };
2860
- const restoreArrays = () => {
2861
- res.set(save.res);
2862
- termRes.set(save.termRes);
2863
- save.termB.forEach((b, k) => termB[k].set(b));
2864
- save.termC.forEach((c, k) => termC[k].set(c));
2865
- termDI.set(save.termDI);
2866
- save.termDX.forEach((x, k) => this.termDX[k].set(x));
2867
- this.transformed = save.transformed;
2868
- this.pins.forEach((p, a) => (p.res = save.pinRes[a]));
2869
- };
2870
- // The same system, its terms kept apart: the rest, and each floating terminal's (unscaled).
2871
- const { rest, lin } = this._assembleDifference(dt);
2872
- const Bk = fl.map((k) => Float64Array.from(termB[k])), Ck = fl.map((k) => Float64Array.from(termC[k])), Dk = fl.map((k) => termDI[k]);
2873
- restoreArrays();
2874
- const v = new Float64Array(N + 1), jv = new Float64Array(N);
2875
- const op = (x) => {
2876
- for (let j = 0; j < N; j++) v[j] = x[j];
2877
- v[N] = 0;
2878
- rest.multiply(v, jv);
2879
- if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
2880
- lin.apply(v, jv, 1, 1 / dt);
2881
- // A flat stretch's rows hold each level to its first node's (see flatRows in src/lawRows.js).
2882
- for (const p of pins) {
2883
- if (!p.col) continue;
2884
- for (const o of p.col) if (o !== p.row && this.loc[o] >= 0) jv[R[o]] -= x[R[p.row]];
2885
- }
2886
- const out = new Float64Array(N + K);
2887
- for (let j = 0; j < N; j++) {
2888
- let t = jv[j];
2889
- for (let a = 0; a < K; a++) t += Bk[a][j] * x[N + a];
2890
- out[j] = t / rowDiv[j];
2891
- }
2892
- for (let a = 0; a < K; a++) {
2893
- let t = Dk[a] * x[N + a];
2894
- for (let b = 0; b < K; b++) if (b !== a) t += this.termDX[fl[a]][fl[b]] * x[N + b];
2895
- for (let j = 0; j < N; j++) t += Ck[a][j] * x[j];
2896
- out[N + a] = t;
2897
- }
2898
- // A pinned row is its constraint's (a conserved amount, an island's summed balance).
2899
- for (const p of pins) {
2900
- let t = 0;
2901
- for (let j = 0; j < p.len; j++) t += p.w[j] * x[p.idx[j]];
2902
- out[R[p.row]] = t;
2903
- }
2904
- return precondition(out);
2905
- };
2906
- const rhs = new Float64Array(N + 1), out = new Float64Array(N + 1), outV = new Float64Array(K);
2907
- const precondition = (r) => {
2908
- for (let j = 0; j < N; j++) rhs[j] = r[j];
2909
- fl.forEach((k, a) => (termRes[k] = r[N + a]));
2910
- for (const p of pins) {
2911
- p.res = rhs[R[p.row]];
2912
- rhs[R[p.row]] = 0;
2913
- }
2914
- solveBordered(this, rhs, out, outV, pins);
2915
- const z = new Float64Array(N + K);
2916
- for (let j = 0; j < N; j++) z[j] = out[j];
2917
- for (let a = 0; a < K; a++) z[N + a] = outV[a];
2918
- return z;
2919
- };
2920
- try {
2921
- const b = new Float64Array(N + K);
2922
- for (let j = 0; j < N; j++) b[j] = save.res[j];
2923
- fl.forEach((k, a) => (b[N + a] = save.termRes[k]));
2924
- pins.forEach((p, a) => (b[R[p.row]] = save.pinRes[a]));
2925
- const pb = precondition(b), x = Float64Array.from(pb);
2926
- // A lost mode or two, GMRES finds in a couple of iterations. The refined update is kept
2927
- // where GMRES converged and its correction is under a thermal unit; otherwise newton()
2928
- // stops refining.
2929
- const x0 = Float64Array.from(x);
2930
- const ok = gmresReal(op, pb, x, { m: 8, restarts: 1, tol: 1e-8 });
2931
- // (Measured on the potentials, as Newton's own step is: a rate the plain solve left
2932
- // unsolved, at a perturbed pivot, can need a correction of 41.7 mol/(m²·s).)
2933
- const dx = new Float64Array(N + 1), dV = new Float64Array(K);
2934
- for (let j = 0; j < N; j++) dx[j] = x[j] - x0[j];
2935
- for (let a = 0; a < K; a++) dV[a] = x[N + a] - x0[N + a];
2936
- if (this.transformed) this._untransform(dx);
2937
- const c = this._maxPotentialStep(dx, dV);
2938
- if (!ok || !(c < 1)) return false;
2939
- for (let j = 0; j < N; j++) delta[j] = x[j];
2940
- for (let a = 0; a < K; a++) deltaV[a] = x[N + a];
2941
- return true;
2942
- } finally {
2943
- restoreArrays();
2944
- }
2945
- }
2946
-
2947
- // Largest update among the potential-like unknowns (φ̂ and η at grid nodes).
2948
- _maxPotentialStep(delta, deltaV = this.deltaV) { // (deltaV null: the device's potentials only)
2949
- const { n, M } = this;
2950
- const R = this.rix; // (the sink entry of delta is 0)
2951
- let mx = 0;
2952
- if (deltaV) for (let a = 0; a < this.floating.length; a++) mx = Math.max(mx, Math.abs(deltaV[a]) / this.VT);
2953
- for (let g = 0; g < this.nNodes; g++) {
2954
- const b = this.blockOfNode[g];
2955
- if (this.nodeConductor[g] < 0) mx = Math.max(mx, Math.abs(delta[R[b * M]])); // (a metal's slot 0 is a flux)
2956
- for (let i = 0; i < n; i++) {
2957
- if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + i]]));
2958
- }
2959
- const k = this.surfPort[g]; // an electrode surface's coverages, as η
2960
- if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
2961
- }
2962
- for (const gt of this.gateList) mx = Math.max(mx, Math.abs(delta[R[gt.o]])); // a gate's fraction open
2963
- return mx;
2964
- }
2965
-
2966
- /**
2967
- * Newton iteration for one backward-Euler step from the current cOld.
2968
- * @returns {{converged: boolean, iterations: number, history: number[]}}
2969
- */
2970
- newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands = false } = {}) {
2971
- const { delta, res } = this;
2972
- const deltaV = this.deltaV ?? (this.deltaV = new Float64Array(this.floating.length));
2973
- const history = [], ownHistory = [];
2974
- // Refined solves (see _refine), once the plain ones stall.
2975
- let refine = false, refined = 0, noRefine = false;
2976
- // Islands (see applyIslands in src/lawRows.js): their summed rows go in only once the plain
2977
- // solves converge.
2978
- // Far from the solution, Newton exact along a weakly held level takes it by 1e5 thermal
2979
- // units (a floating base, cold at bias), where the plain solves, which barely see that level,
2980
- // converge. Converged, an island at the wrong level shows: it takes in a different current
2981
- // than it passes on, so the terminal currents don't add up to zero. Then the summed rows go
2982
- // in and Newton carries on from there. Converged again, a current that tiny against the
2983
- // conduction around it rides on differences (~1e-28 thermal units for 5 fA/m² through a bulk
2984
- // of 1e4 mol/m³ at 1e-4 m²/s) that the last update leaves known only to its own round-off:
2985
- // where the currents still don't add up, one more update polishes them. Where the islands'
2986
- // own system is held by static pivots elsewhere (a GaAs stack's minority carriers, at 1e-17
2987
- // A/m² of noise), their rows can throw Newton off instead: if it doesn't converge again
2988
- // within a dozen iterations, the plain solution stands.
2989
- this.islandsOn = islands;
2990
- let rounds = 0, kept = null, perturbed = false;
2991
- // Converged by a small update, but solved at a perturbed pivot, or in a steady solve (where a
2992
- // factorisation that lost a row's digits left a reaction's rate 3.6e7 off it, the update
2993
- // small, an I = 0 electrode at a voltage 0.4 V from its steady one): the residual itself,
2994
- // assembled at the state, must be small too (each row scaled by its largest Jacobian entry).
2995
- const unsolved = () => {
2996
- if (!perturbed && dt !== Infinity) return false;
2997
- this.assemble(dt);
2998
- this._equilibrate();
2999
- let r = 0;
3000
- for (let k = 0; k < this.SINK; k++) r = Math.max(r, Math.abs(this.res[k]));
3001
- return !(r < 1e-6);
3002
- };
3003
- const islandsOff = (it, result) => {
3004
- if (dt !== Infinity || !this.steady || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3005
- this.islandsOn = true;
3006
- rounds++;
3007
- kept = { it, result, u: Float64Array.from(this.u), uLo: Float64Array.from(this.uLo), termV: Float64Array.from(this.termV) };
3008
- return true;
3009
- };
3010
- // A steady solve that fails: record how nearly singular the system was, and where; or, failing
3011
- // with the islands' rows in, go back to the solution without them.
3012
- const fail = (r) => {
3013
- if (kept) {
3014
- this.u.set(kept.u);
3015
- this.uLo.set(kept.uLo);
3016
- this.termV.set(kept.termV);
3017
- this.islandsOn = false;
3018
- this.computeConcentrations();
3019
- return { ...kept.result, iterations: r.iterations, history };
3020
- }
3021
- if (dt === Infinity) this._noteConditioning();
3022
- return r;
3023
- };
3024
- for (let it = 1; it <= maxIter; it++) {
3025
- if (kept && it - kept.it > 12) return fail({ converged: false, iterations: it - 1, history });
3026
- if (kept && it === kept.it + 2) {
3027
- // Refined from the islands' second update (their first moves the level, perhaps by
3028
- // more than a refinement may), since the plain solves have shown a mode they lose.
3029
- refine = true;
3030
- noRefine = false;
3031
- }
3032
- try {
3033
- this.assemble(dt);
3034
- } catch (err) {
3035
- // A statistics model can fail far from the solution (e.g. Debye–Hückel beyond its range).
3036
- if (!(err instanceof SolverError)) throw err;
3037
- return fail({ converged: false, iterations: it, history, error: err.message });
3038
- }
3039
- this._equilibrate();
3040
- let rmax = 0;
3041
- for (let k = 0; k < this.SINK; k++) rmax = Math.max(rmax, Math.abs(res[k]));
3042
- try {
3043
- this._factor();
3044
- } catch (err) {
3045
- return fail({ converged: false, iterations: it, history, error: err.message });
3046
- }
3047
- // A pivot perturbed (one that cancelled to exactly nothing) can leave a row unsolved, its
3048
- // update small while its residual isn't: the same couple written twice on an electrode at
3049
- // open circuit converged with one spelling's rate row 41.7 mol/(m²·s) off, the couple
3050
- // 1.5 RT from equilibrium. Refine the solves (exact J·v) wherever one was, and before
3051
- // taking a small update for convergence there, look at the residual itself (see unsolved).
3052
- perturbed = this.sys.perturbedPivots > 0;
3053
- if (perturbed && !noRefine) refine = true;
3054
- try {
3055
- const pins = this.pins;
3056
- deltaV.fill(0);
3057
- solveBordered(this, res, delta, deltaV, pins);
3058
- if (refine && !this._refine(dt, delta, deltaV)) {
3059
- refine = false; // (GMRES didn't converge, or strayed: the plain solves it is)
3060
- noRefine = true;
3061
- }
3062
- if (this.transformed) this._untransform(delta);
3063
- } catch (err) {
3064
- if (!(err instanceof SolverError)) throw err;
3065
- return fail({ converged: false, iterations: it, history, error: err.message });
3066
- }
3067
- // Convergence counts every unknown, floating terminal voltages too. Damping and the
3068
- // divergence check count only the device's own potentials: a terminal voltage enters
3069
- // linearly (conductance links, held levels, a capacitive face), so a large swing in one is
3070
- // safe, and damping it would throttle the whole update (a current-driven port switching
3071
- // off, its voltage collapsing by hundreds of volts, crawled 10 thermal units at a time).
3072
- const step = this._maxPotentialStep(delta, deltaV);
3073
- const own = this._maxPotentialStep(delta, null);
3074
- history.push(step);
3075
- ownHistory.push(own);
3076
- if (!Number.isFinite(step)) return fail({ converged: false, iterations: it, history, error: 'non-finite update' });
3077
- // Give up early on clear divergence; the caller will take a smaller step instead.
3078
- if ((it > 1 && own > 1e4) || (it > 6 && own > 10 * ownHistory[0])) {
3079
- this.computeConcentrations();
3080
- return fail({ converged: false, iterations: it, history, error: 'diverging' });
3081
- }
3082
- const alpha = own > maxStep ? maxStep / own : 1;
3083
- this._addToState(delta, -alpha);
3084
- this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
3085
- // A time step's Newton contracts fast from its predictor (1e-2, 1e-9, 1e-15 thermal
3086
- // units). The next update would be about θ = step/(the last one) times this one, so where
3087
- // θ/(1 − θ) of it (Hairer and Wanner's estimate) is under tol, this update converged it:
3088
- // the iteration that would only show that is skipped, a third of a typical step's work.
3089
- const last = history[history.length - 2], theta = step / last;
3090
- const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
3091
- if (alpha === 1 && (step < tol || contracted)) {
3092
- if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3093
- const done = { converged: true, iterations: it, history, residual: rmax };
3094
- if (islandsOff(it, done)) continue;
3095
- this.computeConcentrations();
3096
- return done;
3097
- }
3098
- // Converged as far as round-off allows: the updates are already tiny (below 1e-6 thermal
3099
- // units, ~26 nV) and have stopped shrinking. A badly conditioned system's floor can sit
3100
- // above tol: a strictly neutral material on a short step, where φ is fixed only through
3101
- // fluxes that the storage term dwarfs, rattles at ~1e-9 after converging quadratically.
3102
- // (Raising this floor lets a weakly held population drift: a MOS capacitor without a
3103
- // channel port then showed a DC leak through its oxide.)
3104
- const [p1, p2] = [history[history.length - 2], history[history.length - 3]], floor = 1e-6;
3105
- // Stalled near the solution (not shrinking quadratically), or growing undamped twice
3106
- // running: the factorised Jacobian may have lost a slow mode to cancellation, and
3107
- // round-off's floor would accept it wrong, or its noise keep Newton from the solution (a
3108
- // bipolar stack's floating base, its holes held ~1e14 more weakly than they move within
3109
- // it). Refine the solves from here.
3110
- if (refine) refined++;
3111
- else if (!noRefine && alpha === 1 && it >= 3 && step > 0.25 * p1 && (step < 1e-4 || (step > p1 && p1 > p2))) refine = true;
3112
- if (alpha === 1 && it >= 4 && (!refine || refined >= 1 || noRefine) && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3113
- if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3114
- const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3115
- if (islandsOff(it, done)) continue;
3116
- this.computeConcentrations();
3117
- return done;
3118
- }
3119
- }
3120
- this.computeConcentrations();
3121
- return fail({ converged: false, iterations: maxIter, history });
3122
- }
3123
-
3124
- // How far the terminal currents (as last assembled) are from adding up to zero, relative to the
3125
- // largest of them: Kirchhoff's law, which a steady state keeps.
3126
- _kirchhoff() {
3127
- let sum = 0, mx = 0;
3128
- for (const I of this.termI) {
3129
- sum += I;
3130
- mx = Math.max(mx, Math.abs(I));
3131
- }
3132
- return mx > 0 ? Math.abs(sum) / mx : 0;
3133
- }
3134
-
3135
- // How many digits the last factorisation lost to cancellation, and where (the worst seen since
3136
- // the steady solve began). Only for diagnosing failures: a saturated species, for instance,
3137
- // can lose digits harmlessly.
3138
- _noteConditioning() {
3139
- let c;
3140
- try {
3141
- c = this.sys.cancellation();
3142
- } catch {
3143
- return;
3144
- }
3145
- if (!(c.digits > (this.conditioning?.digits ?? -1))) return;
3146
- const { grid } = this.model;
3147
- let x = NaN, where = '', nodes = [];
3148
- for (let g = 0; g < this.nNodes; g++) {
3149
- if (this.blockOfNode[g] !== c.block) continue;
3150
- x = grid.x[g];
3151
- where = this.model.regions[grid.nodeRegion[g]].name;
3152
- nodes = [g];
3153
- }
3154
- for (let f = 0; f < this.nFaces; f++) {
3155
- if (this.blockOfFace[f] !== c.block) continue;
3156
- x = grid.x[grid.regionEnd[f]];
3157
- where = `interfaces[${f}]`;
3158
- nodes = [grid.regionEnd[f], grid.regionEnd[f] + 1];
3159
- }
3160
- this.conditioning = { digits: c.digits, x, where, nodes };
3161
- }
3162
-
3163
- /**
3164
- * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
3165
- * The end time is always t + dt; the result reports the substeps taken.
3166
- */
3167
- // A time step, halved until it converges (src/timeSteps.js).
3168
- advance(dt, opts, depth = 0) {
3169
- return advanceFn(this, dt, opts, depth);
3170
- }
3171
-
3172
- // u += scale·d in compensated arithmetic (Knuth two-sum, then renormalise hi/lo).
3173
- _addToState(d, scale) {
3174
- const { u, uLo, fullOf } = this;
3175
- for (let j = 0; j < fullOf.length; j++) {
3176
- const k = fullOf[j], a = u[k], b = scale * d[j];
3177
- const s = a + b, bb = s - a;
3178
- const err = a - (s - bb) + (b - bb);
3179
- const lo = uLo[k] + err;
3180
- const hi = s + lo;
3181
- u[k] = hi;
3182
- uLo[k] = lo - (hi - s);
3183
- }
3184
- }
3185
-
3186
- // One time step (src/timeSteps.js).
3187
- step(dt, opts = {}) {
3188
- return stepFn(this, dt, opts);
3189
- }
3190
-
3191
- // Adaptive time stepping to tEnd (src/timeSteps.js).
3192
- integrate(tEnd, opts = {}) {
3193
- return integrateFn(this, tEnd, opts);
3194
- }
3195
-
3196
- // Probes for a transient's trace: { x, species, quantity: 'c' | 'V', region } read after every
3197
- // accepted step, linearly between the two nodes of x's region around it. At an interface x
3198
- // belongs to both regions, so `region` (a name or index) picks the side; it defaults to the
3199
- // first region that holds x.
3200
- _probes(probes) {
3201
- if (probes === undefined) return null;
3202
- const { grid, regions, species } = this.model;
3203
- const fail = (m) => {
3204
- throw new DeviceError(m);
3205
- };
3206
- if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }, or { interface, species } / { interface, gate }');
3207
- const plan = probes.map((p, k) => {
3208
- const path = `advance: probes[${k}]`;
3209
- if (p?.interface !== undefined) {
3210
- // At a face: a species' flux through it (mol/(m²·s), toward +x), or a gate's fraction open.
3211
- const f = p.interface, itf = this.model.interfaces[f];
3212
- if (!(Number.isInteger(f) && itf)) fail(`${path}.interface: no interface ${JSON.stringify(f)} (they're numbered from 0, left to right)`);
3213
- if (p.gate !== undefined) {
3214
- const q = itf.gates.findIndex((gt) => gt.name === p.gate);
3215
- if (q < 0) fail(`${path}.gate: interfaces[${f}] has no gate ${JSON.stringify(p.gate)}${itf.gates.length ? ` (it has ${itf.gates.map((gt) => gt.name).join(', ')})` : ''}`);
3216
- return { o: this._gateSlot(f, q) };
3217
- }
3218
- const i = species.findIndex((sp) => sp.name === p.species);
3219
- if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p.species)}`);
3220
- return { o: this.blockOfFace[f] * this.M + 1 + i };
3221
- }
3222
- const quantity = p?.quantity ?? 'c';
3223
- if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
3224
- const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
3225
- if (quantity !== 'phi' && i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
3226
- if (quantity === 'V' && species[i].z === 0) fail(`${path}: '${p.species}' is neutral, so it has no voltage; read 'c'`);
3227
- const x = grid.x, inside = (r) => p.x >= x[grid.regionStart[r]] && p.x <= x[grid.regionEnd[r]];
3228
- let r;
3229
- if (p.region !== undefined) {
3230
- r = typeof p.region === 'number' ? p.region : regions.findIndex((reg) => reg.name === p.region);
3231
- if (!(r >= 0 && r < regions.length)) fail(`${path}.region: no region ${JSON.stringify(p.region)}`);
3232
- if (!inside(r)) fail(`${path}.x (${p.x} m) is outside region ${JSON.stringify(p.region)}`);
3233
- } else {
3234
- // At a face, the side where the probe has something to read; both, and it must be told.
3235
- const has = (rr) => {
3236
- const mat = this.model.materials[regions[rr].material];
3237
- return i < 0 ? !mat.conductor && !mat.phiFree : mat.present[i];
3238
- };
3239
- const sides = regions.flatMap((_, rr) => (inside(rr) ? [rr] : []));
3240
- if (!(Number.isFinite(p.x) && sides.length)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
3241
- const able = sides.filter(has);
3242
- if (able.length > 1) fail(`${path}.x (${p.x} m) is on the face between ${able.map((rr) => JSON.stringify(regions[rr].name)).join(' and ')}: give region, the side to read`);
3243
- r = able[0] ?? sides[0];
3244
- }
3245
- if (i >= 0 && !this.model.materials[regions[r].material].present[i]) fail(`${path}: '${p.species}' is absent from region ${JSON.stringify(regions[r].name)}, so there's nothing to read at x = ${p.x} m`);
3246
- let g = grid.regionStart[r];
3247
- while (g + 1 < grid.regionEnd[r] && x[g + 1] < p.x) g++;
3248
- const w = x[g + 1] > x[g] ? (p.x - x[g]) / (x[g + 1] - x[g]) : 0;
3249
- return { i, g, w, quantity };
3250
- });
3251
- const out = plan.map(() => []);
3252
- const { n, M } = this;
3253
- const at = (q, g) => {
3254
- if (q.quantity === 'phi') return this.phiUndefined[g] ? NaN : this.VT * (this.u[this.blockOfNode[g] * M] + this.uLo[this.blockOfNode[g] * M]);
3255
- if (!this.present[g * n + q.i]) return NaN;
3256
- if (q.quantity === 'c') return this.c[g * n + q.i];
3257
- const o = this.blockOfNode[g] * M + 1 + q.i;
3258
- return (this.VT * (this.u[o] + this.uLo[o])) / this.z[q.i];
3259
- };
3260
- return {
3261
- out,
3262
- read: () => plan.map((q) => (q.o !== undefined ? (this.loc[q.o] < 0 ? 0 : this.u[q.o]) : (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1))),
3263
- push: (values) => values.forEach((v, k) => out[k].push(v)),
3264
- };
3265
- }
3266
-
3267
- // Start from another solver's state for the same device on another grid (a refined one): each
3268
- // node's unknowns interpolated linearly in x within its region, each face's copied, and the
3269
- // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
3270
- _warmFrom(src) {
3271
- const { M, u, uLo } = this, grid = this.model.grid, sg = src.model.grid;
3272
- if (src.M !== M || sg.regionStart.length !== grid.regionStart.length) throw new Error('_warmFrom: a different device');
3273
- const value = (b, k) => src.u[b * M + k] + src.uLo[b * M + k];
3274
- for (let r = 0; r < grid.regionStart.length; r++) {
3275
- let a = sg.regionStart[r];
3276
- for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
3277
- const x = grid.x[g];
3278
- while (a + 1 < sg.regionEnd[r] && sg.x[a + 1] < x) a++;
3279
- const b = Math.min(a + 1, sg.regionEnd[r]), h = sg.x[b] - sg.x[a];
3280
- const w = h > 0 ? Math.min(1, Math.max(0, (x - sg.x[a]) / h)) : 0;
3281
- const ba = src.blockOfNode[a], bb = src.blockOfNode[b], bg = this.blockOfNode[g];
3282
- for (let k = 0; k < M; k++) {
3283
- u[bg * M + k] = (1 - w) * value(ba, k) + w * value(bb, k);
3284
- uLo[bg * M + k] = 0;
3285
- }
3286
- }
3287
- }
3288
- for (let f = 0; f < this.nFaces; f++) {
3289
- for (let k = 0; k < M; k++) {
3290
- u[this.blockOfFace[f] * M + k] = value(src.blockOfFace[f], k);
3291
- uLo[this.blockOfFace[f] * M + k] = 0;
3292
- }
3293
- }
3294
- this.termV.set(src.termV);
3295
- this.solvedV = src.solvedV;
3296
- this.time = src.time;
3297
- this.computeConcentrations();
837
+ this.termV.set(src.termV);
838
+ this.solvedV = src.solvedV;
839
+ this.coldStart = src.coldStart;
840
+ this.time = src.time;
841
+ this.computeConcentrations();
3298
842
  }
3299
843
 
3300
844
  _snapshot() {
@@ -3318,6 +862,7 @@ export class Solver {
3318
862
  history: this.history.slice(),
3319
863
  termV: Float64Array.from(this.termV),
3320
864
  sourceTime: this.sourceTime,
865
+ coldStart: this.coldStart,
3321
866
  };
3322
867
  }
3323
868
 
@@ -3341,6 +886,7 @@ export class Solver {
3341
886
  this.history = s.history.slice();
3342
887
  this.termV.set(s.termV);
3343
888
  this.sourceTime = s.sourceTime;
889
+ this.coldStart = s.coldStart;
3344
890
  this.computeConcentrations();
3345
891
  }
3346
892
 
@@ -3363,453 +909,6 @@ export class Solver {
3363
909
  return I + (Number.isFinite(dt) ? (this.contactD.right - this.contactDOld.right) / dt : 0);
3364
910
  }
3365
911
 
3366
- /**
3367
- * Small-signal impedance about the current (steady) state, seen at one terminal. Linearising
3368
- * gives (J + iωM)·δx + Σ B_k δV_k = rhs for the grid's rows, and (C_k + iωC′_k)·δx +
3369
- * (∂I_k/∂V_k)·δV_k = δI_set,k for each floating terminal's circuit row, with J the steady
3370
- * Jacobian and M the storage part (from J(dt) = J + M/dt), B_k = ∂res/∂V_k and C_k = ∂I_k/∂x.
3371
- * At the measured terminal, a held voltage is perturbed (rhs = −B δV) and its current read
3372
- * from C, or a driven current is perturbed and its voltage solved for. The other terminals
3373
- * keep their drives: held ones at AC ground, driven ones at AC open circuit.
3374
- * Z = δV/δI with I into the device (positive real part for a passive device).
3375
- * @param {ArrayLike<number>} frequencies Hz
3376
- */
3377
- impedance(frequencies, opts) {
3378
- const was = this.allTerminals;
3379
- this.allTerminals = true; // (it reads its terminal's ∂res/∂V and ∂I/∂x)
3380
- try {
3381
- return this._impedance(frequencies, opts);
3382
- } finally {
3383
- this.allTerminals = was;
3384
- }
3385
- }
3386
-
3387
- _impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3388
- const { M, nB, sys, VT } = this;
3389
- const kT = this.terms.findIndex((t) => t.name === terminal);
3390
- if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
3391
- const dT = this.terms[kT].drive;
3392
- if (dT.R > 0) throw new SolverError(`impedance: terminal '${terminal}' has a series resistance; that belongs to the external circuit`);
3393
- // A small signal returns through the terminals held at a voltage: with every other one driven
3394
- // by a current (held open), none comes back, and the impedance is infinite (else round-off).
3395
- const others = this.terms.filter((t, k) => k !== kT);
3396
- if (others.length && others.every((t) => t.drive.kind === 'I')) {
3397
- throw new SolverError(
3398
- `impedance: every other terminal (${others.map((t) => t.name).join(', ')}) is driven by a current, so it is open to a small signal and no current returns through '${terminal}': ` +
3399
- `its impedance is infinite. Measure at one of those instead (impedance(f, { terminal: '${others[0].name}' }), its voltage against '${terminal}'), or hold one of them at a voltage`,
3400
- );
3401
- }
3402
- const N = nB * M, fl = this.floating, K = fl.length;
3403
- this.sourceTime = this.time;
3404
- this.computeConcentrations();
3405
- this.cOld.set(this.c);
3406
- this.thOld.set(this.th);
3407
- this.th0Old.set(this.th0);
3408
- this.gateList.forEach((gt, j) => (this.gateOld[j] = this.u[gt.o]));
3409
- this.assemble(Infinity);
3410
- this.contactDStart = { ...this.contactD };
3411
- this.portQStart = Float64Array.from(this.portQ);
3412
- this.assemble(Infinity);
3413
- // The steady parts, then the storage parts from an assembly at a tiny dt.
3414
- const J = { A: sys.A.slice(), B: sys.B.slice(), C: sys.C.slice() };
3415
- const Bt = this.termB.map((v) => v.slice()), Ct = this.termC.map((v) => v.slice()), DI = Float64Array.from(this.termDI);
3416
- const dts = 1e-30;
3417
- this.assemble(dts);
3418
- const S = {};
3419
- for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
3420
- const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
3421
- const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
3422
- // Where the measured contact's current may be read across a segment: anywhere without
3423
- // ports; with them, only between it and the nearest window (a port's exchange takes current
3424
- // in or out along it). Never for a port's own terminal.
3425
- let cuts = kT < 2 ? this._currentCuts() : null;
3426
- if (cuts && this.model.ports.length > 0) {
3427
- const windows = this.model.ports.flatMap((p) => Array.from(p.nodes));
3428
- const lo = Math.min(...windows), hi = Math.max(...windows);
3429
- cuts = cuts.filter((cut) => (kT === 0 ? cut.s + 1 <= lo : cut.s >= hi));
3430
- }
3431
- // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
3432
- // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
3433
- // flux to round-off. So the dilute kernels' terms are kept in difference form (see
3434
- // DifferenceTerms), with the rest from a matrix of their own, steady (Jr) and storage (Sr).
3435
- this._assembleDifference(Infinity);
3436
- const Jr = { A: this.sysRest.A.slice(), B: this.sysRest.B.slice(), C: this.sysRest.C.slice() };
3437
- const { lin } = this._assembleDifference(dts);
3438
- const Sr = {};
3439
- for (const X of ['A', 'B', 'C']) Sr[X] = this.sysRest[X].map((v, k) => (v - Jr[X][k]) * dts);
3440
- this.assemble(Infinity);
3441
-
3442
- const { sizes, offA, offB, offC, offX } = sys;
3443
- const csys = new ComplexBlockTridiagonal(nB, sizes);
3444
- const Nc = csys.size;
3445
- const rr = new Float64Array(Nc), ri = new Float64Array(Nc);
3446
- const yr = new Float64Array(Nc), yi = new Float64Array(Nc);
3447
- const Xr = fl.map(() => new Float64Array(Nc)), Xi = fl.map(() => new Float64Array(Nc));
3448
- const xr = new Float64Array(N), xi = new Float64Array(N);
3449
- const rRes = new Float64Array(Nc), iRes = new Float64Array(Nc);
3450
- const jr = new Float64Array(Nc), ji = new Float64Array(Nc), sr = new Float64Array(Nc), si = new Float64Array(Nc);
3451
- const out = { f: Float64Array.from(frequencies), Z: { re: new Float64Array(frequencies.length), im: new Float64Array(frequencies.length) } };
3452
- if (profiles) out.profiles = [];
3453
- // (C + iωC′)·(ar + i ai) for terminal k
3454
- const dot = (k, w, ar, ai) => {
3455
- let re = 0, im = 0;
3456
- const c = Ct[k], cs = Cs[k];
3457
- for (let j = 0; j < Nc; j++) {
3458
- re += c[j] * ar[j] - w * cs[j] * ai[j];
3459
- im += c[j] * ai[j] + w * cs[j] * ar[j];
3460
- }
3461
- return [re, im];
3462
- };
3463
- Array.from(frequencies).forEach((f, q) => {
3464
- const w = 2 * Math.PI * f;
3465
- if (!(w > 0)) throw new SolverError('impedance: frequencies must be positive');
3466
- // Rows scaled by their largest entry (and the terminal columns with them).
3467
- const scale = new Float64Array(Nc);
3468
- for (let blk = 0; blk < nB; blk++) {
3469
- const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
3470
- for (let r = 0; r < m; r++) {
3471
- const rows = [['A', offA[blk] + r * mp, mp], ['B', offB[blk] + r * m, m], ['C', offC[blk] + r * mn, mn]];
3472
- let mx = 0;
3473
- for (const [X, o, len] of rows) for (let c = 0; c < len; c++) mx = Math.max(mx, Math.abs(J[X][o + c]), w * Math.abs(S[X][o + c]));
3474
- const sc = mx > 0 ? 1 / mx : 1;
3475
- scale[offX[blk] + r] = sc;
3476
- for (const [X, o, len] of rows) {
3477
- const JX = J[X], SX = S[X], Xre = csys[X + 'r'], Xim = csys[X + 'i'];
3478
- for (let c = 0; c < len; c++) {
3479
- Xre[o + c] = JX[o + c] * sc;
3480
- Xim[o + c] = w * SX[o + c] * sc;
3481
- }
3482
- }
3483
- }
3484
- }
3485
- csys.factor();
3486
- const sx = (X, vr, out) => {
3487
- for (let blk = 0; blk < nB; blk++) {
3488
- const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
3489
- for (let r = 0; r < m; r++) {
3490
- let acc = 0;
3491
- for (let c = 0; c < mp; c++) acc += X.A[offA[blk] + r * mp + c] * vr[offX[blk - 1] + c];
3492
- for (let c = 0; c < m; c++) acc += X.B[offB[blk] + r * m + c] * vr[offX[blk] + c];
3493
- for (let c = 0; c < mn; c++) acc += X.C[offC[blk] + r * mn + c] * vr[offX[blk + 1] + c];
3494
- out[offX[blk] + r] = acc;
3495
- }
3496
- }
3497
- };
3498
- // A x = −σ ∂res/∂V_k, x = outr + i outi (σ = 1 for the response to the measured terminal,
3499
- // −1 for a floating one's unit δV): GMRES on the system preconditioned by the factorised one
3500
- // (whose error is concentrated in a few slow modes, which it finds in a few iterations).
3501
- const zero = new Float64Array(Nc);
3502
- const precondition = (ar, ai) => {
3503
- const outR = new Float64Array(Nc), outI = new Float64Array(Nc);
3504
- for (let j = 0; j < Nc; j++) {
3505
- rRes[j] = ar[j] * scale[j];
3506
- iRes[j] = ai[j] * scale[j];
3507
- }
3508
- csys.solve(rRes, iRes, outR, outI);
3509
- return [outR, outI];
3510
- };
3511
- // (J + iωS)·v, exactly (see DifferenceTerms), into (ar, ai).
3512
- const jv = (vr, vi, ar, ai) => {
3513
- sx(Jr, vr, jr);
3514
- sx(Jr, vi, ji);
3515
- sx(Sr, vi, si);
3516
- sx(Sr, vr, sr);
3517
- for (let j = 0; j < Nc; j++) {
3518
- ar[j] = jr[j] - w * si[j];
3519
- ai[j] = ji[j] + w * sr[j];
3520
- }
3521
- lin.apply(vr, ar, 1, 0);
3522
- lin.apply(vi, ar, 0, -w);
3523
- lin.apply(vi, ai, 1, 0);
3524
- lin.apply(vr, ai, 0, w);
3525
- };
3526
- const op = ([vr, vi]) => {
3527
- const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3528
- jv(vr, vi, ar, ai);
3529
- return precondition(ar, ai);
3530
- };
3531
- const solve = (br, bi, outr, outi, k, sigma) => {
3532
- const rhs = k >= 0 ? precondition(Bt[k].map((v) => -sigma * v), zero) : [new Float64Array(Nc), new Float64Array(Nc)];
3533
- outr.set(rhs[0]);
3534
- outi.set(rhs[1]);
3535
- // (GMRES keeps its best iterate, which is never worse than the factorised solve.)
3536
- const { residual } = gmres(op, rhs, [outr, outi]);
3537
- accuracy = Math.max(accuracy, residual);
3538
- };
3539
- let accuracy = 1e-14; // (relative, of the solves: the readouts' error is weighed with it)
3540
- // y: the response with the floating terminals held; X_k: to a unit δV_k.
3541
- const heldT = dT.kind === 'V';
3542
- for (let j = 0; j < Nc; j++) {
3543
- rr[j] = heldT ? -Bt[kT][j] * scale[j] : 0;
3544
- ri[j] = 0;
3545
- }
3546
- solve(rr, ri, yr, yi, heldT ? kT : -1, heldT ? 1 : 0);
3547
- fl.forEach((k, a) => {
3548
- for (let j = 0; j < Nc; j++) rr[j] = Bt[k][j] * scale[j];
3549
- ri.fill(0);
3550
- solve(rr, ri, Xr[a], Xi[a], k, -1);
3551
- });
3552
- // The floating terminals' circuit rows, a small complex system for their δV.
3553
- const Ar = Array.from({ length: K }, () => new Float64Array(K + 1)), Ai = Array.from({ length: K }, () => new Float64Array(K + 1));
3554
- fl.forEach((k, a) => {
3555
- for (let b = 0; b < K; b++) {
3556
- const [re, im] = dot(k, w, Xr[b], Xi[b]);
3557
- Ar[a][b] = -re;
3558
- Ai[a][b] = -im;
3559
- }
3560
- Ar[a][a] += DI[k];
3561
- Ai[a][a] += w * DIs[k];
3562
- const [yre, yim] = dot(k, w, yr, yi);
3563
- Ar[a][K] = (k === kT ? 1 : 0) - yre;
3564
- Ai[a][K] = -yim;
3565
- });
3566
- const dV = complexSolve(Ar, Ai, K);
3567
- // δx = y − Σ X_k δV_k
3568
- for (let j = 0; j < Nc; j++) {
3569
- let re = yr[j], im = yi[j];
3570
- for (let a = 0; a < K; a++) {
3571
- re -= Xr[a][j] * dV.re[a] - Xi[a][j] * dV.im[a];
3572
- im -= Xr[a][j] * dV.im[a] + Xi[a][j] * dV.re[a];
3573
- }
3574
- yr[j] = re;
3575
- yi[j] = im;
3576
- xr[this.fullOf[j]] = re;
3577
- xi[this.fullOf[j]] = im;
3578
- }
3579
- // The current into terminal kT per volt, (C + iωC′)·δx + (∂I/∂V)(1 + iω′), with its error.
3580
- // At the contact it's a sum whose terms can be far larger than itself (in a junction's
3581
- // neutral ends, a conductance times a δη that cancels its neighbours' to 1e-17), and its
3582
- // round-off reads as a constant conductance. Between the two contacts of a device without
3583
- // ports, the total current (conduction and displacement) is the same through every cut, so
3584
- // it's also read across each segment, and the reading with the least error taken. Each
3585
- // reading's error is its terms' round-off and the solves' error in δx (to `accuracy` of its
3586
- // largest entry) through its coefficients: a contact's or a cut's flux is a difference of
3587
- // levels that the solved rows don't pin.
3588
- // `parts`: the response as a sum, each part compact (r, i) and full-index (fr, fi), with the
3589
- // absolute error of its entries.
3590
- const read = (parts) => {
3591
- const eps = 1.1e-16, c = Ct[kT], cs = Cs[kT];
3592
- let Ir = DI[kT], Ii = w * DIs[kT], err = eps * (Math.abs(DI[kT]) + w * Math.abs(DIs[kT]));
3593
- for (const p of parts) {
3594
- const [re, im] = dot(kT, w, p.r, p.i);
3595
- Ir += re;
3596
- Ii += im;
3597
- for (let j = 0; j < Nc; j++) err += (Math.abs(c[j]) + w * Math.abs(cs[j])) * (eps * Math.hypot(p.r[j], p.i[j]) + p.floor);
3598
- }
3599
- if (!cuts) return { Ir, Ii, err };
3600
- const sgn = kT === 0 ? 1 : -1;
3601
- for (const cut of cuts) {
3602
- let re = 0, im = 0, e = 0;
3603
- for (const p of parts) {
3604
- for (let a = 0; a < cut.idx.length; a++) {
3605
- const x = cut.idx[a], gk = cut.g[a], dk = w * cut.d[a];
3606
- // (g + iωd)(xr + i xi)
3607
- re += gk * p.fr[x] - dk * p.fi[x];
3608
- im += gk * p.fi[x] + dk * p.fr[x];
3609
- e += (Math.abs(gk) + Math.abs(dk)) * (eps * Math.hypot(p.fr[x], p.fi[x]) + p.floor);
3610
- }
3611
- }
3612
- if (e < err) [err, Ir, Ii] = [e, sgn * re, sgn * im];
3613
- }
3614
- return { Ir, Ii, err };
3615
- };
3616
- const largest = (r, i) => {
3617
- let m = 0;
3618
- for (let j = 0; j < r.length; j++) m = Math.max(m, Math.hypot(r[j], i[j]));
3619
- return m;
3620
- };
3621
- const full = (r, i) => {
3622
- const fr = new Float64Array(N), fi = new Float64Array(N);
3623
- for (let j = 0; j < Nc; j++) {
3624
- fr[this.fullOf[j]] = r[j];
3625
- fi[this.fullOf[j]] = i[j];
3626
- }
3627
- return [fr, fi];
3628
- };
3629
- let Zr, Zi;
3630
- if (heldT) {
3631
- // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal…
3632
- let { Ir, Ii, err } = read([{ r: yr, i: yi, fr: xr, fi: xi, floor: accuracy * largest(yr, yi) }]);
3633
- // …and where the solves leave that unresolved (the estimate past 1% of the current), the
3634
- // response is split: a uniform shift of each region (z_i s on every η_i, s on φ̂), which
3635
- // changes no flux and no concentration, so that J·e vanishes inside each region and is
3636
- // taken at its edges; and what's left, small, solved for to GMRES's floor, its error now
3637
- // relative to itself. An electrolyte at its open circuit needs it: the response to its
3638
- // terminal is nearly such a shift, and its current the slope of levels uniform to 1e-14,
3639
- // more than a double can hold. (A port's exchange sees the shift: its window's rows are
3640
- // kept with the edges'.) So does a MOS capacitor's back contact, its channel held by a port.
3641
- if (K === 0 && cuts && err > 1e-2 * Math.hypot(Ir, Ii)) {
3642
- const [er, ei] = this._regionShift(yr, yi);
3643
- const tr = new Float64Array(Nc), ti = new Float64Array(Nc);
3644
- jv(er, ei, tr, ti);
3645
- const edge = this._regionEdgeRows();
3646
- const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3647
- for (let j = 0; j < Nc; j++) {
3648
- ar[j] = -Bt[kT][j] - (edge[j] ? tr[j] : 0);
3649
- ai[j] = edge[j] ? -ti[j] : 0;
3650
- }
3651
- const rhs = precondition(ar, ai);
3652
- const dr = yr.map((v, j) => v - er[j]), di = yi.map((v, j) => v - ei[j]);
3653
- const { residual } = gmres(op, rhs, [dr, di], { tol: 1e-11 });
3654
- const [efr, efi] = full(er, ei), [dfr, dfi] = full(dr, di);
3655
- const r2 = read([
3656
- { r: er, i: ei, fr: efr, fi: efi, floor: 0 },
3657
- { r: dr, i: di, fr: dfr, fi: dfi, floor: Math.max(1e-14, residual) * largest(dr, di) },
3658
- ]);
3659
- if (r2.err < err) ({ Ir, Ii, err } = r2);
3660
- if (profiles) {
3661
- for (let j = 0; j < N; j++) {
3662
- xr[j] = efr[j] + dfr[j];
3663
- xi[j] = efi[j] + dfi[j];
3664
- }
3665
- }
3666
- }
3667
- const d2 = Ir * Ir + Ii * Ii; // Z = 1/δI
3668
- Zr = Ir / d2;
3669
- Zi = -Ii / d2;
3670
- } else {
3671
- // δV per unit current
3672
- const a = fl.indexOf(kT);
3673
- Zr = dV.re[a];
3674
- Zi = dV.im[a];
3675
- }
3676
- out.Z.re[q] = Zr;
3677
- out.Z.im[q] = Zi;
3678
- if (profiles) out.profiles.push(this._smallSignalProfiles(xr, xi));
3679
- });
3680
- return out;
3681
- }
3682
-
3683
- // A uniform shift of each region, fitted to a small-signal response (compact, complex): φ̂ by
3684
- // s_r and each species' η by z_i s_r, with s_r the region's mean δφ̂ (or, where φ̂ isn't an
3685
- // unknown, its carriers' mean δη/z). Coverages and flux unknowns aren't shifted.
3686
- _regionShift(yr, yi) {
3687
- const { n, M, z, loc, model } = this, R = this.rix, { grid } = model;
3688
- const er = new Float64Array(yr.length), ei = new Float64Array(yr.length);
3689
- for (let r = 0; r < model.regions.length; r++) {
3690
- let sr = 0, si = 0, k = 0;
3691
- const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
3692
- for (let g = g0; g <= g1; g++) {
3693
- const o = this.blockOfNode[g] * M;
3694
- if (loc[o] >= 0) {
3695
- sr += yr[R[o]];
3696
- si += yi[R[o]];
3697
- k++;
3698
- }
3699
- }
3700
- if (k === 0) {
3701
- for (let g = g0; g <= g1; g++) {
3702
- const o = this.blockOfNode[g] * M;
3703
- for (let i = 0; i < n; i++) {
3704
- if (z[i] === 0 || loc[o + 1 + i] < 0) continue;
3705
- sr += yr[R[o + 1 + i]] / z[i];
3706
- si += yi[R[o + 1 + i]] / z[i];
3707
- k++;
3708
- }
3709
- }
3710
- }
3711
- if (k === 0) continue;
3712
- sr /= k;
3713
- si /= k;
3714
- for (let g = g0; g <= g1; g++) {
3715
- const o = this.blockOfNode[g] * M;
3716
- if (loc[o] >= 0) {
3717
- er[R[o]] = sr;
3718
- ei[R[o]] = si;
3719
- }
3720
- for (let i = 0; i < n; i++) {
3721
- if (loc[o + 1 + i] < 0) continue;
3722
- er[R[o + 1 + i]] = z[i] * sr;
3723
- ei[R[o + 1 + i]] = z[i] * si;
3724
- }
3725
- }
3726
- }
3727
- return [er, ei];
3728
- }
3729
-
3730
- // Compact rows a region's uniform shift can touch: faces, the nodes beside them, the two ends
3731
- // and the ports' windows (whatever holds a level from outside). Elsewhere inside a region,
3732
- // every row is unchanged by it.
3733
- _regionEdgeRows() {
3734
- const { M, loc, nB } = this, R = this.rix;
3735
- const edge = new Uint8Array(this.sys.size);
3736
- const mark = (b) => {
3737
- if (b < 0 || b >= nB) return;
3738
- for (let r = 0; r < M; r++) if (loc[b * M + r] >= 0) edge[R[b * M + r]] = 1;
3739
- };
3740
- mark(0);
3741
- mark(nB - 1);
3742
- for (let f = 0; f < this.nFaces; f++) {
3743
- const b = this.blockOfFace[f];
3744
- mark(b - 1);
3745
- mark(b);
3746
- mark(b + 1);
3747
- }
3748
- for (const port of this.model.ports) for (const g of port.nodes) mark(this.blockOfNode[g]);
3749
- return edge;
3750
- }
3751
-
3752
- // The cuts where the impedance may read a small-signal current. Each is the linearised total
3753
- // current toward +x through one segment s of a region, Σ (g + iω d)·δu over the unknowns idx:
3754
- // the Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute, or _segmentConcentrated
3755
- // for non-ideal statistics) and the displacement's. Without ports it's the same through every cut; with them, only between a
3756
- // contact and the nearest port's window (see _impedance).
3757
- _currentCuts() {
3758
- const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
3759
- const { grid, materials, regions } = model;
3760
- const cuts = [];
3761
- for (let r = 0; r < regions.length; r++) {
3762
- const reg = regions[r], mat = materials[reg.material];
3763
- if (mat.conductor || reg.mixing > 0) continue;
3764
- const vel = reg.velocity;
3765
- for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
3766
- const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
3767
- const terms = new Map();
3768
- const add = (x, g, d) => {
3769
- const t = terms.get(x) || [0, 0];
3770
- t[0] += g;
3771
- t[1] += d;
3772
- terms.set(x, t);
3773
- };
3774
- const phiL = u[bL * M], phiR = u[bR * M], hasPhi = !this.phiUndefined[s];
3775
- for (let i = 0; i < n; i++) {
3776
- if (z[i] === 0 || !mat.present[i] || mat.D[i] === 0) continue;
3777
- if (!mat.ideal) {
3778
- this._fluxConcentrated(s, bL, bR, mat, h, vel, i);
3779
- for (let k = 0; k < M; k++) {
3780
- if (k === 0 && !hasPhi) continue;
3781
- if (this.jL[k] !== 0) add(bL * M + k, z[i] * F * this.jL[k], 0);
3782
- if (this.jR[k] !== 0) add(bR * M + k, z[i] * F * this.jR[k], 0);
3783
- }
3784
- continue;
3785
- }
3786
- const zi = z[i], r1 = 1 + i, cL = c[s * n + i], g = mat.D[i] / h;
3787
- const pe = (vel * h) / mat.D[i];
3788
- const d = zi * (phiR - phiL) - pe;
3789
- const E = Math.expm1(u[bR * M + r1] - u[bL * M + r1] + (uLo[bR * M + r1] - uLo[bL * M + r1]) - pe);
3790
- const gBc = g * bernoulli(d) * cL;
3791
- const dNdd = -g * bernoulliDerivative(d) * cL * E;
3792
- add(bL * M + r1, zi * F * gBc, 0);
3793
- add(bR * M + r1, -zi * F * gBc * (E + 1), 0);
3794
- if (hasPhi) {
3795
- add(bL * M, zi * F * (-zi * dNdd + zi * gBc * E), 0);
3796
- add(bR * M, zi * F * zi * dNdd, 0);
3797
- }
3798
- }
3799
- // The displacement toward +x, ε(φ_L − φ_R)/h (in φ̂ = φ/V_T).
3800
- const k = hasPhi ? (mat.epsr * EPS0 * this.VT) / h : 0;
3801
- if (k > 0) {
3802
- add(bL * M, 0, k);
3803
- add(bR * M, 0, -k);
3804
- }
3805
- if (terms.size === 0) continue;
3806
- const idx = [...terms.keys()];
3807
- cuts.push({ s, idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
3808
- }
3809
- }
3810
- return cuts;
3811
- }
3812
-
3813
912
  // Each species' flux toward +x across every segment, at the current state (concentrations
3814
913
  // computed): totals through the cross-section, mol/s (mol/(m²·s) for a planar device). A face's
3815
914
  // segment, between its pair of nodes, carries the face's flux; a conductor's, its carrier's.
@@ -3831,11 +930,17 @@ export class Solver {
3831
930
  const pe = (vel * h) / mat.D[i];
3832
931
  const d = z[i] * (u[bR * M] - u[bL * M]) + ex[(s + 1) * n + i] - ex[s * n + i] - pe;
3833
932
  const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]) - pe;
3834
- out[i][s] = -(mat.D[i] / h) * bernoulli(d) * c[s * n + i] * Math.expm1(deta);
933
+ out[i][s] = sgFlux(mat.D[i] / h, d, deta, c[s * n + i], c[(s + 1) * n + i], this.sg)[0];
3835
934
  }
3836
935
  if (reg.mixing > 0) {
3837
- // Eddy mixing (see _segmentMixing): −(D_mix/h) c̄_i (Δη_i − z_i q), c̄ the logarithmic mean.
936
+ // Eddy mixing (see _segmentMixing): −(D_mix/h) c̄_i (Δη_i − z_i q), c̄ the logarithmic
937
+ // mean; between crowded ends, _mixCrowded's.
3838
938
  const on = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0);
939
+ if (!this.nodeIdeal[s] || !this.nodeIdeal[s + 1]) {
940
+ const { N } = this._mixBase(s, s + 1, bL, bR, on);
941
+ on.forEach((i, p) => (out[i][s] += (reg.mixing / h) * N[p]));
942
+ continue;
943
+ }
3839
944
  const cb = [], de = [];
3840
945
  let S = 0, Q = 0;
3841
946
  for (const k of on) {
@@ -3872,89 +977,11 @@ export class Solver {
3872
977
  const gated = link.gates ?? [];
3873
978
  const P = link.P * (gated.length ? this._open(gated, (q) => u[this._gateSlot(f, q)]) : 1);
3874
979
  const right = P * bernoulli(d) * this.c[kl];
3875
- return [right, right * Math.exp(deta)];
980
+ // (far up a drift, right × e^Δη is 0·∞: the backward one is then sgFlux's, whole)
981
+ return [right, d < SG_FAR ? right * Math.exp(deta) : sgFlux(P, d, deta, this.c[kl], this.c[kr], this.sg)[3]];
3876
982
  });
3877
983
  }
3878
984
 
3879
- // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
3880
- _smallSignalProfiles(xr, xi) {
3881
- const { n, M, z, VT, model } = this;
3882
- const nN = this.nNodes, RT = model.RT;
3883
- const pair = () => ({ re: new Float64Array(nN), im: new Float64Array(nN) });
3884
- const phi = pair(), mu = {}, c = {};
3885
- for (const sp of model.species) {
3886
- mu[sp.name] = pair();
3887
- c[sp.name] = pair();
3888
- }
3889
- for (let g = 0; g < nN; g++) {
3890
- const b = this.blockOfNode[g];
3891
- const undef = this.phiUndefined[g];
3892
- phi.re[g] = undef ? NaN : VT * xr[b * M];
3893
- phi.im[g] = undef ? NaN : VT * xi[b * M];
3894
- for (let i = 0; i < n; i++) {
3895
- const name = model.species[i].name;
3896
- if (!this.present[g * n + i]) {
3897
- mu[name].re[g] = mu[name].im[g] = c[name].re[g] = c[name].im[g] = NaN;
3898
- continue;
3899
- }
3900
- mu[name].re[g] = RT * xr[b * M + 1 + i];
3901
- mu[name].im[g] = RT * xi[b * M + 1 + i];
3902
- if (this.nodeConductor[g] >= 0) {
3903
- // a metal has a Fermi level but no concentration (as a solution reports it)
3904
- c[name].re[g] = c[name].im[g] = NaN;
3905
- continue;
3906
- }
3907
- // δc_i = Σ_j K_ij (δη_j − z_j δφ̂)
3908
- let sr = 0, si = 0;
3909
- for (let j = 0; j < n; j++) {
3910
- const Kij = this.nodeIdeal[g] ? (i === j ? this.c[g * n + i] : 0) : this.K[g * n * n + i * n + j];
3911
- if (Kij === 0) continue;
3912
- sr += Kij * (xr[b * M + 1 + j] - z[j] * xr[b * M]);
3913
- si += Kij * (xi[b * M + 1 + j] - z[j] * xi[b * M]);
3914
- }
3915
- c[name].re[g] = sr;
3916
- c[name].im[g] = si;
3917
- }
3918
- }
3919
- return { phi, mu, c };
3920
- }
3921
-
3922
- // The contact and port readouts (fluxes, displacements, the last segment's current) at the
3923
- // current state, from only the boxes they're read from. Each of those boxes gets the same terms
3924
- // in the same order as in assemble(), so the readouts are identical; other residuals and the
3925
- // Jacobian are left partial (the next assemble() starts afresh). Concentrations must be current.
3926
- _assembleBookkeeping(dt) {
3927
- const { model } = this;
3928
- this.dtNow = dt;
3929
- const { grid, materials, regions } = model;
3930
- this.res.fill(0);
3931
- for (let r = 0; r < regions.length; r++) {
3932
- const { nodes, segs } = this.bookkeeping[r];
3933
- if (nodes.length === 0 && segs.length === 0) continue;
3934
- const reg = regions[r], mat = materials[reg.material];
3935
- if (mat.conductor) {
3936
- for (const g of nodes) this._nodeConductor(g, dt);
3937
- for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s] / grid.segArea[s]);
3938
- continue;
3939
- }
3940
- for (const g of nodes) {
3941
- if (mat.ideal) this._nodesDilute(g, g, dt);
3942
- else this._nodeConcentrated(g, dt);
3943
- }
3944
- const rxs = this.rxsIn[reg.material];
3945
- if (rxs.length > 0) for (const g of nodes) this._bulkReactions(g, rxs);
3946
- for (const s of segs) {
3947
- const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s]; // (a length over the cross-section: fluxes are totals)
3948
- this._segmentDisplacement(s, bL, bR, mat, h);
3949
- if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
3950
- if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
3951
- }
3952
- if (mat.ideal) for (const s of segs) this._segmentsDilute(s, s + 1, r, mat, reg.velocity);
3953
- }
3954
- for (const f of this.bookkeepingFaces) this._face(f);
3955
- this._terminals(dt);
3956
- }
3957
-
3958
985
  // Add this step's contact fluxes (at the converged state) to each stretch's intake.
3959
986
  // (A BDF2 step also moves each amount by Σ v (c* − c_n), its history term.)
3960
987
  _accumulateBoundaryIntake(dt, cN) {
@@ -3984,503 +1011,11 @@ export class Solver {
3984
1011
  this.boundaryIntake[k] += q * dt + hist;
3985
1012
  });
3986
1013
  }
1014
+ }
3987
1015
 
3988
- // Slowest diffusion time across the device, used to size "giant" steps.
3989
- slowestTime() {
3990
- const { model } = this;
3991
- let dMin = Infinity;
3992
- for (const mat of model.materials) {
3993
- for (let i = 0; i < this.n; i++) if (mat.present[i] && mat.D[i] > 0) dMin = Math.min(dMin, mat.D[i]);
3994
- }
3995
- const L = model.grid.length;
3996
- return Number.isFinite(dMin) ? (L * L) / dMin : 1;
3997
- }
3998
-
3999
- // The fastest diffusion time: across the smallest cell, at the largest D.
4000
- fastestTime() {
4001
- let dMax = 0;
4002
- for (const mat of this.model.materials) {
4003
- for (let i = 0; i < this.n; i++) if (mat.present[i]) dMax = Math.max(dMax, mat.D[i]);
4004
- }
4005
- let h = Infinity;
4006
- for (const s of this.model.grid.segLength) if (s > 0) h = Math.min(h, s);
4007
- return dMax > 0 && Number.isFinite(h) ? (h * h) / dMax : this.slowestTime();
4008
- }
4009
-
4010
- /**
4011
- * Steady state: where a transient from the present state ends, found as the solution of the
4012
- * steady equations (dt = ∞, no storage term), with no slow modes to wait out, however slow the
4013
- * physics (e.g. exponentially scarce minority carriers). What the storage term held is held by
4014
- * the steady system's own rows instead (see src/laws.js): each conserved amount in place of a balance
4015
- * row that says nothing new, levels that carry nothing held flat, islands' summed balances.
4016
- * Where Newton from here fails: continuation along the drives, then the pseudo-transient ramp.
4017
- * The clock is not advanced, and open-system conservation bookkeeping restarts here.
4018
- */
4019
- // Steady state from the present one; `atSteady` says whether the state is one (until a step or
4020
- // a change of drive).
4021
- solveSteady(opts = {}) {
4022
- this.violent = this.history.length === 0 && !this.stepped ? violentStart(this) : []; // (for a failure to say: see src/timeSteps.js)
4023
- this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4024
- this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
4025
- captureLaws(this);
4026
- this.steady = true; // (the steady system's rows; sources at the present time)
4027
- this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
4028
- try {
4029
- return this._solveSteadyAll(opts);
4030
- } finally {
4031
- this.steady = false;
4032
- }
4033
- }
4034
-
4035
- _solveSteadyAll(opts) {
4036
- // A terminal driven by a current that can only store what comes in (a battery's cathode
4037
- // behind it, closed but for that terminal; a gate, or a capacitance with no species through
4038
- // it) has no steady state but at no current, where it keeps what it holds.
4039
- const { species, regions } = this.model;
4040
- for (const k of this.floating) {
4041
- const I = sourceAt(this.terms[k].drive.src, this.time);
4042
- if (this.terms[k].drive.kind !== 'I' || I === 0) continue;
4043
- const laws = this.constraints.filter((cs) => cs.terminals.includes(k));
4044
- if (laws.length === 0 && !chargeOnly(this, k)) continue;
4045
- const what = laws.flatMap((cs) => cs.parts.map(({ stretch }) => {
4046
- const st = this.stretches[stretch], [r0, r1] = st.regions;
4047
- return `${species[st.species].name} in ${regions[r0].name}${r1 > r0 ? `…${regions[r1].name}` : ''}`;
4048
- }));
4049
- this.charging = { terminal: this.terms[k].name, current: I, what };
4050
- return { converged: false, steps: 0, iterations: 0, history: [] };
4051
- }
4052
- const r = this._steadyFromHere(opts);
4053
- this.atSteady = r.converged;
4054
- return r;
4055
- }
4056
-
4057
- // Steady state from the present one, in three stages: Newton from here; failing that, from a
4058
- // solved state along the drives (the light ramped up, a current's terminal held at the voltages
4059
- // that pass it, the voltages ramped from where they were last solved); failing that, the
4060
- // pseudo-transient ramp, through time.
4061
- _steadyFromHere(opts) {
4062
- this.conditioning = null;
4063
- this.computeConcentrations();
4064
- captureLocal(this); // immobile combinations keep, node by node, what they hold now
4065
- this.sourceTime = this.time;
4066
- // Continuation applies where the right terminal is held at a voltage, against a held left one.
4067
- const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
4068
- const held = (d) => d.kind === 'V' && !(d.R > 0);
4069
- const target = sourceAt(dr.src, this.time), level = sourceAt(dl.src, this.time);
4070
- const allowed = opts.continuation !== false;
4071
- const canContinue = allowed && held(dl) && held(dr) && target !== level;
4072
- const direct = this._directSteady();
4073
- const u0 = Float64Array.from(this.u), u0Lo = Float64Array.from(this.uLo), v0 = Float64Array.from(this.termV);
4074
- const restart = () => {
4075
- this.u.set(u0);
4076
- this.uLo.set(u0Lo);
4077
- this.termV.set(v0);
4078
- this.computeConcentrations();
4079
- };
4080
- const solved = (q) => {
4081
- if (q.converged) {
4082
- this.solvedV = [level, target];
4083
- this.stepped = false;
4084
- }
4085
- return q;
4086
- };
4087
- const after = (q, r) => ({ ...q, steps: q.steps + r.steps, iterations: q.iterations + r.iterations });
4088
-
4089
- // 1. Newton from here. Where a continuation could follow, one direct attempt only, rather
4090
- // than the pseudo-transient ramp straight away.
4091
- const quick = (canContinue || this.hasGeneration) && direct && allowed;
4092
- let r = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
4093
- if (r.converged) return solved(r);
4094
- // A transient's state can be a worse start than a cold one: 1 ns after a lit MOS capacitor's
4095
- // gate jumps, its surface is in deep depletion, minority carriers ten orders below where they
4096
- // end, and Newton fails from there where it converges from the layout for the drives (and
4097
- // the stages below took 9000 iterations from there, 2000 from the layout). What a steady
4098
- // solve keeps it took from the state already, so from here on it goes as a cold device would.
4099
- if (this.stepped && direct && allowed) {
4100
- this._refreshSources();
4101
- this.initFromComposition();
4102
- u0.set(this.u);
4103
- u0Lo.set(this.uLo);
4104
- const q = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
4105
- if (q.converged) return solved(after(q, r));
4106
- restart();
4107
- r = after(q, r);
4108
- }
4109
-
4110
- // 2. Continuation. Generation (e.g. light) holding the device far from equilibrium: ramp it up
4111
- // from nearly nothing, each solve warm from the last.
4112
- let dimmer = false;
4113
- if (this.hasGeneration && allowed) {
4114
- restart();
4115
- this.dimmer = false;
4116
- const g = this._generationContinuation(opts, r);
4117
- dimmer = this.dimmer;
4118
- if (g.converged) return solved(g);
4119
- r = g;
4120
- }
4121
- // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
4122
- // voltage until the current crosses its target, and float it from there.
4123
- if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
4124
- restart();
4125
- const q = this._floatingContinuation(opts, r);
4126
- if (q.converged) return q;
4127
- r = q;
4128
- }
4129
- if (canContinue) {
4130
- // The right terminal's voltage ramped to its target: from the contacts' voltages at the last
4131
- // converged solve when the state is that solution (a warm start), the left one first where
4132
- // it moved, the right held where it was; otherwise from level terminals, consistent with a
4133
- // cold start.
4134
- restart();
4135
- const was = this.solvedV;
4136
- let c;
4137
- if (was) {
4138
- c = was[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, was[0], level, r, true, was[1]);
4139
- // (Ramping the right from level terminals, solve there first: a sweep's start.)
4140
- if (c.converged) c = this._continuation(opts, 1, was[1], target, c, c.ramped || was[1] !== level);
4141
- } else c = this._continuation(opts, 1, level, target, r, false);
4142
- if (c.converged) return solved(c);
4143
- // 3. The pseudo-transient ramp, from the start.
4144
- restart();
4145
- return solved(after(this._steadySteps(opts), c));
4146
- }
4147
- // 3. The pseudo-transient ramp (where the first attempt wasn't it already): from the dimmer
4148
- // light's solution where the light's continuation got anywhere (a lit floating base charges
4149
- // from there as it would in time), else from the start.
4150
- if (!quick) return r;
4151
- if (!dimmer) restart();
4152
- else this.computeConcentrations();
4153
- return after(this._steadySteps(opts), r);
4154
- }
4155
-
4156
- // Whether terminal k, driven by a current, feeds a combination that it alone can change, or
4157
- // only charges a capacitance (so that its steady states are one per amount or charge, not one
4158
- // per voltage).
4159
- _bound(k) {
4160
- return this.constraints.some((cs) => cs.terminals.includes(k)) || chargeOnly(this, k);
4161
- }
4162
-
4163
- // A cold steady solve with a terminal driven by a current, which can fail where the same state
4164
- // is easy warm (a solar cell's open circuit, from cold on a fine grid): the terminal held at a
4165
- // voltage instead, whose steady solves have their own continuation, the voltage marched from
4166
- // the start's until the current crosses its target, the crossing narrowed by bisection, and
4167
- // the terminal floated again from beside it.
4168
- _floatingContinuation(opts, r) {
4169
- const k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j));
4170
- const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
4171
- let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
4172
- const history = (r.history ?? []).slice();
4173
- const hold = (V) => {
4174
- this.terms[k].drive = { kind: 'V', src: { value: V }, R: 0 };
4175
- this.redrive();
4176
- const q = this._steadyFromHere(opts);
4177
- steps += q.steps ?? 0;
4178
- iterations += q.iterations ?? 0;
4179
- history.push({ held: V, converged: q.converged });
4180
- if (q.converged) seen.push([V, this.termI[k]]);
4181
- return q.converged ? this.termI[k] - target : NaN;
4182
- };
4183
- const fail = () => ({ converged: false, steps, iterations, history });
4184
- const seen = []; // [V, I] of each held solve that converged
4185
- let Va = this.termV[k], fa, Vb, fb;
4186
- try {
4187
- fa = hold(Va);
4188
- if (!Number.isFinite(fa)) {
4189
- Va = 0;
4190
- fa = hold(Va);
4191
- }
4192
- if (!Number.isFinite(fa)) return fail();
4193
- // March, the step growing: up where the current is below its target, since raising a
4194
- // terminal's voltage raises the current into a passive device (the other way, from the
4195
- // start again, if 20 V brings no crossing).
4196
- const start = Va, f0 = fa;
4197
- [Vb, fb] = [Va, fa];
4198
- for (const dir of [fa < 0 ? 1 : -1, fa < 0 ? -1 : 1]) {
4199
- [Va, fa] = [start, f0];
4200
- if (dir !== (f0 < 0 ? 1 : -1)) hold(start); // (back to the start, for a warm march)
4201
- let dV = 0.02;
4202
- Vb = Va + dir * dV;
4203
- fb = hold(Vb);
4204
- while (Number.isFinite(fb) && Math.sign(fb) === Math.sign(fa) && fa !== 0 && Math.abs(Vb - start) < 20) {
4205
- [Va, fa] = [Vb, fb];
4206
- dV *= 1.5;
4207
- Vb = Va + dir * dV;
4208
- fb = hold(Vb);
4209
- }
4210
- if (Number.isFinite(fb) && (Math.sign(fb) !== Math.sign(fa) || fa === 0)) break;
4211
- }
4212
- if (!Number.isFinite(fb) || (Math.sign(fb) === Math.sign(fa) && fa !== 0)) {
4213
- // What the held voltages passed, at the two extremes reached.
4214
- if (seen.length > 1) {
4215
- seen.sort((a, b) => a[0] - b[0]);
4216
- this.unreached = { terminal: this.terms[k].name, target, low: seen[0], high: seen.at(-1) };
4217
- }
4218
- return fail();
4219
- }
4220
- // Bisect (each held solve warm from the last) to a tenth of a millivolt.
4221
- for (let it = 0; it < 40 && Math.abs(Vb - Va) > 1e-4; it++) {
4222
- const Vm = (Va + Vb) / 2, fm = hold(Vm);
4223
- if (!Number.isFinite(fm)) return fail();
4224
- if (Math.sign(fm) === Math.sign(fa)) [Va, fa] = [Vm, fm];
4225
- else [Vb, fb] = [Vm, fm];
4226
- }
4227
- hold(Math.abs(fa) < Math.abs(fb) ? Va : Vb);
4228
- } finally {
4229
- this.terms[k].drive = drive;
4230
- this.redrive();
4231
- }
4232
- const q = this._steadySteps(opts);
4233
- steps += q.steps ?? 0;
4234
- iterations += q.iterations ?? 0;
4235
- history.push(...(q.history ?? []));
4236
- if (q.converged) return { ...q, steps, iterations, history };
4237
- // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
4238
- // limit: its bordered row 33 digits short), but a current-driven steady state is the held
4239
- // one at the voltage that passes the target: found by held solves, by regula falsi
4240
- // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
4241
- // current the march saw (a target of 0 is never met exactly).
4242
- const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
4243
- try {
4244
- let side = 0;
4245
- for (let it = 0; it < 60; it++) {
4246
- const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
4247
- if (!Number.isFinite(fm)) return fail();
4248
- if (Math.abs(fm) <= 1e-10 * scale) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4249
- if (Math.sign(fm) === Math.sign(fb)) {
4250
- [Vb, fb] = [Vm, fm];
4251
- if (side === -1) fa /= 2;
4252
- side = -1;
4253
- } else {
4254
- [Va, fa] = [Vm, fm];
4255
- if (side === 1) fb /= 2;
4256
- side = 1;
4257
- }
4258
- }
4259
- return fail();
4260
- } finally {
4261
- this.terms[k].drive = drive;
4262
- this.redrive();
4263
- }
4264
- }
4265
-
4266
- // Steady solves with the generation reactions' rates scaled from 1e-12 up to 1, ×100 a step
4267
- // while they converge, smaller steps where they don't.
4268
- _generationContinuation(opts, r) {
4269
- const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
4270
- let s = 1e-30, factor = 100, steps = r.steps, iterations = r.iterations;
4271
- const history = r.history.slice();
4272
- try {
4273
- this.generationScale = s;
4274
- // (nearly dark: a cold start can need the full ramp, as a dark device's does)
4275
- let q = this._steadySteps(sub);
4276
- steps += q.steps;
4277
- iterations += q.iterations;
4278
- if (!q.converged) return { converged: false, steps, iterations, history };
4279
- this.dimmer = true; // (the state is now a solution under dimmer light: a better start than cold)
4280
- while (s < 1) {
4281
- const next = Math.min(1, s * factor);
4282
- const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4283
- this.generationScale = next;
4284
- q = this._steadySteps(sub);
4285
- steps += q.steps;
4286
- iterations += q.iterations;
4287
- history.push({ generation: next, converged: q.converged });
4288
- if (q.converged) {
4289
- s = next;
4290
- factor = Math.min(100, factor * factor);
4291
- } else {
4292
- this.u.set(u1);
4293
- this.uLo.set(u1Lo);
4294
- this.termV.set(v1);
4295
- this.computeConcentrations();
4296
- factor = Math.sqrt(factor);
4297
- if (factor < 1.5) return { converged: false, steps, iterations, history }; // (crawling: time steps do better)
4298
- }
4299
- }
4300
- return { converged: true, steps, iterations, history };
4301
- } finally {
4302
- this.generationScale = 1;
4303
- }
4304
- }
4305
-
4306
- // Terminal k (a contact) ramped from `level` to `target` through steady solves, each warm from
4307
- // the last (`hold`: the other contact's voltage meanwhile, if not its own). A warm ramp that has
4308
- // nowhere to go converges only if what it started from did (`ramped`): the state it carries
4309
- // must be some ramp's solution, never just the one a failed solve left.
4310
- _continuation(opts, k, level, target, r, warm, hold) {
4311
- const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
4312
- let V = level, dV = (target - level) / 8, steps = r.steps, iterations = r.iterations;
4313
- const history = r.history.slice();
4314
- if (warm && V === target) return { ...r, converged: r.ramped === true, steps, iterations, history, ramped: r.ramped };
4315
- try {
4316
- this.sourceOverride.set(k, V);
4317
- if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4318
- let q;
4319
- if (!warm) {
4320
- q = this._steadySteps(opts);
4321
- if (!q.converged) {
4322
- // A cold device's start was laid out for the target (a set() before the first solve
4323
- // builds it there): lay it out again, level.
4324
- steps += q.steps;
4325
- iterations += q.iterations;
4326
- this._refreshSources();
4327
- this.initFromComposition();
4328
- q = this._steadySteps(opts);
4329
- }
4330
- // (lit, the level start may need the light ramped up too)
4331
- if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
4332
- steps += q.steps;
4333
- iterations += q.iterations;
4334
- if (!q.converged) return { ...r, steps, iterations };
4335
- }
4336
- while (V !== target) {
4337
- const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4338
- const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4339
- this.sourceOverride.set(k, next);
4340
- q = this._steadySteps(sub);
4341
- steps += q.steps;
4342
- iterations += q.iterations;
4343
- history.push({ continuation: next, converged: q.converged });
4344
- if (q.converged) {
4345
- V = next;
4346
- dV *= 1.5;
4347
- } else {
4348
- this.u.set(u1);
4349
- this.uLo.set(u1Lo);
4350
- this.termV.set(v1);
4351
- this.computeConcentrations();
4352
- dV /= 4;
4353
- if (Math.abs(dV) < 1e-6 * Math.abs(target - level)) return { converged: false, steps, iterations, history };
4354
- }
4355
- }
4356
- return { converged: true, steps, iterations, history, ramped: true };
4357
- } finally {
4358
- this.sourceOverride.delete(k);
4359
- if (hold !== undefined) this.sourceOverride.delete(1 - k);
4360
- this._refreshSources();
4361
- }
4362
- }
4363
-
4364
- _steadySteps({ maxSteps = 80, tol = 1e-11 } = {}) {
4365
- const time = this.time;
4366
- const tau = this.slowestTime();
4367
- const direct = this._directSteady();
4368
- const giant = direct ? Infinity : 1e6 * tau;
4369
- let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
4370
- let triedDirect = 0;
4371
- let totalIter = 0, steps = 0, converged = false;
4372
- const history = [];
4373
- while (steps < maxSteps) {
4374
- steps++;
4375
- // Restore conserved amounts exactly before each huge step; the step then re-solves, so
4376
- // the final state satisfies every equation.
4377
- if (!direct && dt >= giant) this._renormalizeSpectators();
4378
- let r = this.step(dt);
4379
- totalIter += r.iterations;
4380
- history.push({ dt, converged: r.converged, iterations: r.iterations });
4381
- if (!r.converged && dt === Infinity && steps === 1) {
4382
- // A large change can overshoot Newton's usual damping: retry with a tighter limit.
4383
- r = this.step(dt, { maxStep: 3, maxIter: 80 });
4384
- totalIter += r.iterations;
4385
- history.push({ dt, converged: r.converged, iterations: r.iterations, maxStep: 3 });
4386
- }
4387
- if (!r.converged) {
4388
- // Pseudo-transient continuation: down from a slow time scale until a step converges,
4389
- // as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
4390
- dt = dt >= giant ? tau * 1e-6 : dt / 4;
4391
- grow = 2;
4392
- if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
4393
- continue;
4394
- }
4395
- if (dt === Infinity) {
4396
- converged = true; // the steady equations themselves were solved
4397
- break;
4398
- }
4399
- // Past the slowest diffusion time, the direct solve may already reach from here: try it
4400
- // once a decade (the state kept if it fails), rather than step on to the giant dt through
4401
- // steps that can fail for slow ions' sake.
4402
- if (direct && dt < giant && dt >= tau && dt >= 10 * triedDirect) {
4403
- triedDirect = dt;
4404
- const snap = this._snapshot();
4405
- const d = this.step(Infinity);
4406
- totalIter += d.iterations;
4407
- history.push({ dt: Infinity, converged: d.converged, iterations: d.iterations });
4408
- if (d.converged) {
4409
- converged = true;
4410
- break;
4411
- }
4412
- this._restore(snap);
4413
- }
4414
- if (dt >= giant && this._maxPotentialStep(this._diff()) < tol) {
4415
- converged = true;
4416
- if (dt > giant) {
4417
- // Finish at the base giant step, where the storage term pins amounts tightly.
4418
- this._renormalizeSpectators();
4419
- const f = this.step(giant);
4420
- totalIter += f.iterations;
4421
- converged = f.converged;
4422
- }
4423
- break;
4424
- }
4425
- if (dt < giant) {
4426
- dt *= grow; // ramping up after a failure
4427
- grow = Math.min(10, grow * 1.5);
4428
- if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
4429
- } else if (dt < 1e6 * giant) {
4430
- dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)
4431
- }
4432
- }
4433
- this.time = time;
4434
- this.history = []; // a steady solve isn't a trajectory: transients restart from here
4435
- this.dtNext = undefined;
4436
- if (converged) {
4437
- this.stretches.forEach((st, k) => {
4438
- if (!st.connected) return;
4439
- this.referenceAmounts[k] = this.amount(st);
4440
- this.boundaryIntake[k] = 0;
4441
- });
4442
- }
4443
- return { converged, steps, iterations: totalIter, history };
4444
- }
4445
-
4446
- // Huge steps pin each conserved amount only through a tiny storage term, so round-off can let
4447
- // it creep. Shift each spectator's level uniformly to restore its amount exactly (in one step
4448
- // for ideal statistics, where c ∝ e^η; by Newton on the shift otherwise).
4449
- _renormalizeSpectators() {
4450
- const { n, M, u } = this;
4451
- let changed = false;
4452
- this.stretches.forEach((st, k) => {
4453
- // A floating conductor holds its charge on its faces, not as an amount: the huge steps keep
4454
- // it, and there's nothing here to shift.
4455
- if (!st.spectator || this.model.materials[this.model.regions[st.regions[0]].material].conductor) return;
4456
- const want = this.referenceAmounts[k];
4457
- const ideal = this.nodeIdeal.subarray(st.nodes[0], st.nodes[1] + 1).every((v) => v === 1);
4458
- for (let it = 0; it < (ideal ? 1 : 30); it++) {
4459
- const now = this.amount(st);
4460
- if (!(now > 0) || now === want) return;
4461
- let shift;
4462
- if (ideal) shift = Math.log(want / now);
4463
- else {
4464
- let dA = 0;
4465
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
4466
- const dc = this.nodeIdeal[g] ? this.c[g * n + st.species] : this.K[g * n * n + st.species * (n + 1)];
4467
- dA += this.model.grid.vol[g] * dc;
4468
- }
4469
- shift = (want - now) / dA;
4470
- if (!(Math.abs(shift) > 1e-16)) return;
4471
- }
4472
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) u[this.blockOfNode[g] * M + 1 + st.species] += shift;
4473
- changed = true;
4474
- if (!ideal) this.computeConcentrations();
4475
- }
4476
- });
4477
- if (changed) this.computeConcentrations();
4478
- }
4479
-
4480
- _diff() {
4481
- const d = this.delta; // reuse as scratch
4482
- const { fullOf } = this;
4483
- for (let j = 0; j < fullOf.length; j++) d[j] = this.u[fullOf[j]] - this.uPrev[fullOf[j]];
4484
- return d;
4485
- }
1016
+ // The assembly's methods, the steady solves' and the small signal's, defined with their contracts
1017
+ // in src/assemble.js, src/regions.js, src/boundaries.js, src/steady.js and src/impedance.js, are
1018
+ // the Solver's own.
1019
+ for (const part of [Assembly, RegionTerms, BoundaryTerms, SteadySolves, SmallSignal]) {
1020
+ for (const [k, d] of Object.entries(Object.getOwnPropertyDescriptors(part.prototype))) if (k !== 'constructor') Object.defineProperty(Solver.prototype, k, d);
4486
1021
  }