driftlet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +285 -0
  3. package/llms.txt +299 -0
  4. package/package.json +54 -0
  5. package/src/bernoulli.js +29 -0
  6. package/src/blockTridiagonal.js +492 -0
  7. package/src/constants.js +36 -0
  8. package/src/data.js +127 -0
  9. package/src/describe.js +190 -0
  10. package/src/device.js +954 -0
  11. package/src/equation.js +188 -0
  12. package/src/errors.js +8 -0
  13. package/src/fermi.js +106 -0
  14. package/src/grid.js +111 -0
  15. package/src/index.js +207 -0
  16. package/src/kit.js +76 -0
  17. package/src/live.js +217 -0
  18. package/src/merge.js +11 -0
  19. package/src/plot.js +267 -0
  20. package/src/solution.js +246 -0
  21. package/src/solver.js +2884 -0
  22. package/src/stack.js +172 -0
  23. package/src/statistics.js +593 -0
  24. package/src/traces.js +125 -0
  25. package/src/types.js +221 -0
  26. package/src/worker.js +24 -0
  27. package/types/bernoulli.d.ts +4 -0
  28. package/types/blockTridiagonal.d.ts +92 -0
  29. package/types/constants.d.ts +33 -0
  30. package/types/data.d.ts +73 -0
  31. package/types/describe.d.ts +15 -0
  32. package/types/device.d.ts +20 -0
  33. package/types/equation.d.ts +102 -0
  34. package/types/errors.d.ts +3 -0
  35. package/types/fermi.d.ts +3 -0
  36. package/types/grid.d.ts +28 -0
  37. package/types/index.d.ts +103 -0
  38. package/types/kit.d.ts +61 -0
  39. package/types/live.d.ts +101 -0
  40. package/types/merge.d.ts +1 -0
  41. package/types/plot.d.ts +60 -0
  42. package/types/solution.d.ts +18 -0
  43. package/types/solver.d.ts +342 -0
  44. package/types/stack.d.ts +59 -0
  45. package/types/statistics.d.ts +10 -0
  46. package/types/traces.d.ts +118 -0
  47. package/types/types.d.ts +574 -0
package/src/solver.js ADDED
@@ -0,0 +1,2884 @@
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
+ return { g: e * Math.expm1(a), gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
6
+ }
7
+
8
+ // Solve the K×K complex system (Ar + i Ai) x = (last column), partial pivoting by modulus.
9
+ function complexSolve(Ar, Ai, K) {
10
+ for (let col = 0; col < K; col++) {
11
+ let p = col;
12
+ 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;
13
+ [Ar[col], Ar[p]] = [Ar[p], Ar[col]];
14
+ [Ai[col], Ai[p]] = [Ai[p], Ai[col]];
15
+ const dr = Ar[col][col], di = Ai[col][col], d2 = dr * dr + di * di;
16
+ for (let r = col + 1; r < K; r++) {
17
+ const fr = (Ar[r][col] * dr + Ai[r][col] * di) / d2, fi = (Ai[r][col] * dr - Ar[r][col] * di) / d2;
18
+ for (let k = col; k <= K; k++) {
19
+ const ur = Ar[col][k], ui = Ai[col][k];
20
+ Ar[r][k] -= fr * ur - fi * ui;
21
+ Ai[r][k] -= fr * ui + fi * ur;
22
+ }
23
+ }
24
+ }
25
+ const re = new Float64Array(K), im = new Float64Array(K);
26
+ for (let r = K - 1; r >= 0; r--) {
27
+ let vr = Ar[r][K], vi = Ai[r][K];
28
+ for (let k = r + 1; k < K; k++) {
29
+ vr -= Ar[r][k] * re[k] - Ai[r][k] * im[k];
30
+ vi -= Ar[r][k] * im[k] + Ai[r][k] * re[k];
31
+ }
32
+ const dr = Ar[r][r], di = Ai[r][r], d2 = dr * dr + di * di;
33
+ re[r] = (vr * dr + vi * di) / d2;
34
+ im[r] = (vi * dr - vr * di) / d2;
35
+ }
36
+ return { re, im };
37
+ }
38
+
39
+ // GMRES(m), restarted, for a complex system op(x) = b, with vectors as [re, im] pairs and op
40
+ // already preconditioned; x holds the starting guess and receives the answer.
41
+ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
42
+ const N = b[0].length;
43
+ const cdot = (a, c) => {
44
+ let re = 0, im = 0;
45
+ for (let j = 0; j < N; j++) {
46
+ re += a[0][j] * c[0][j] + a[1][j] * c[1][j];
47
+ im += a[0][j] * c[1][j] - a[1][j] * c[0][j];
48
+ }
49
+ return [re, im]; // conj(a)·c
50
+ };
51
+ const norm = (a) => Math.sqrt(cdot(a, a)[0]);
52
+ const bn = norm(b) || 1;
53
+ for (let cycle = 0; cycle < restarts; cycle++) {
54
+ 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])];
55
+ const beta = norm(r);
56
+ if (!(beta > tol * bn)) return;
57
+ const V = [[r[0].map((v) => v / beta), r[1].map((v) => v / beta)]];
58
+ const H = Array.from({ length: m + 1 }, () => Array.from({ length: m }, () => [0, 0]));
59
+ const cs = [], sn = [], g = Array.from({ length: m + 1 }, () => [0, 0]);
60
+ g[0] = [beta, 0];
61
+ let k = 0, converged = false;
62
+ for (; k < m; k++) {
63
+ const w = op(V[k]);
64
+ for (let i = 0; i <= k; i++) {
65
+ const [hr, hi] = cdot(V[i], w);
66
+ H[i][k] = [hr, hi];
67
+ for (let j = 0; j < N; j++) {
68
+ w[0][j] -= hr * V[i][0][j] - hi * V[i][1][j];
69
+ w[1][j] -= hr * V[i][1][j] + hi * V[i][0][j];
70
+ }
71
+ }
72
+ const hn = norm(w);
73
+ H[k + 1][k] = [hn, 0];
74
+ V.push([w[0].map((v) => v / (hn || 1)), w[1].map((v) => v / (hn || 1))]);
75
+ // The earlier rotations, then a new one zeroing H[k+1][k]: x' = c x + s y, y' = −s̄ x + c y.
76
+ for (let i = 0; i < k; i++) {
77
+ const [xr, xi] = H[i][k], [yr, yi] = H[i + 1][k], c = cs[i], [sr, si] = sn[i];
78
+ H[i][k] = [c * xr + sr * yr - si * yi, c * xi + sr * yi + si * yr];
79
+ H[i + 1][k] = [-(sr * xr + si * xi) + c * yr, -(sr * xi - si * xr) + c * yi];
80
+ }
81
+ const [ar, ai] = H[k][k], am = Math.hypot(ar, ai), t = Math.hypot(am, hn);
82
+ const c = t > 0 ? am / t : 1, s = am > 0 ? [(ar / am) * (hn / t), (ai / am) * (hn / t)] : [1, 0];
83
+ cs.push(c);
84
+ sn.push(s);
85
+ H[k][k] = am > 0 ? [(ar / am) * t, (ai / am) * t] : [hn, 0];
86
+ H[k + 1][k] = [0, 0];
87
+ const [gr, gi] = g[k];
88
+ g[k] = [c * gr, c * gi];
89
+ g[k + 1] = [-(s[0] * gr + s[1] * gi), -(s[0] * gi - s[1] * gr)];
90
+ if (!(Math.hypot(g[k + 1][0], g[k + 1][1]) > tol * bn)) {
91
+ k++;
92
+ converged = true;
93
+ break;
94
+ }
95
+ }
96
+ // Back-substitute H y = g over the first k columns, then x += V y.
97
+ const y = Array.from({ length: k }, () => [0, 0]);
98
+ for (let i = k - 1; i >= 0; i--) {
99
+ let [vr, vi] = g[i];
100
+ for (let j = i + 1; j < k; j++) {
101
+ const [hr, hi] = H[i][j], [yr, yi] = y[j];
102
+ vr -= hr * yr - hi * yi;
103
+ vi -= hr * yi + hi * yr;
104
+ }
105
+ const [dr, di] = H[i][i], d2 = dr * dr + di * di;
106
+ y[i] = [(vr * dr + vi * di) / d2, (vi * dr - vr * di) / d2];
107
+ }
108
+ for (let i = 0; i < k; i++) {
109
+ const [yr, yi] = y[i];
110
+ for (let j = 0; j < N; j++) {
111
+ x[0][j] += yr * V[i][0][j] - yi * V[i][1][j];
112
+ x[1][j] += yr * V[i][1][j] + yi * V[i][0][j];
113
+ }
114
+ }
115
+ if (converged) return; // (by its own residual estimate)
116
+ }
117
+ }
118
+
119
+ // Discretisation and nonlinear solver.
120
+ //
121
+ // Unknowns, per solver block (M = 1 + nSpecies slots):
122
+ // grid node: [φ̂, η_1 … η_n], with φ̂ = Fφ/RT and η_i = μ̄_i/RT
123
+ // flux node: [D, N_1 … N_n, r_1 … r_K], the displacement and particle fluxes through an
124
+ // interface, and the rate of each reaction at it
125
+ // Every region boundary is a doubled grid node (one per side) with a zero-volume flux node
126
+ // between them in the linear system, so the Jacobian stays block-tridiagonal.
127
+ //
128
+ // Not every slot is an unknown everywhere: an absent species, φ where it's undefined, a blocked
129
+ // interface flux. The state u keeps every slot (one that isn't an unknown just keeps its value),
130
+ // but the residual, the update and the Jacobian hold only the unknowns: block b has as many as
131
+ // it has active slots, and the linear system's block sizes vary from node to node. `loc` maps a
132
+ // slot to its row within its block (or −1), and `rix` to its index in the compact vectors (or
133
+ // the sink, one spare entry at the end that absorbs writes for slots that aren't unknowns).
134
+ //
135
+ // Balance rows (node g, box volume v per unit area):
136
+ // φ: D_out − D_in − v·(F Σ z_i c_i + ρ_fixed) = 0
137
+ // i: v·(c_i − c_i,old)/dt + N_out − N_in = 0
138
+ // Fluxes along segments are Scharfetter–Gummel. Each flux is computed once per segment and
139
+ // added with opposite signs to both neighbours, so sums over boxes telescope exactly.
140
+ //
141
+ // Concentrations come from each material's statistics, c(ζ) with ζ_i = η_i − μ°_i/RT − z_i φ̂,
142
+ // and K = ∂c/∂ζ. Nodes of ideal materials (c = c_ref e^ζ) take a fast path throughout.
143
+
144
+ import { BlockTridiagonal, ComplexBlockTridiagonal } from './blockTridiagonal.js';
145
+ import { bernoulli, bernoulliDerivative } from './bernoulli.js';
146
+ import { EPS0, FARADAY } from './constants.js';
147
+ import { nextBreakpoint, sourceAt } from './device.js';
148
+
149
+ export class SolverError extends Error {
150
+ constructor(message, details) {
151
+ super(message);
152
+ this.name = 'SolverError';
153
+ this.details = details;
154
+ }
155
+ }
156
+
157
+ export class Solver {
158
+ constructor(model) {
159
+ this.model = model;
160
+ const { grid, species, materials, regions, contacts } = model;
161
+ const n = species.length;
162
+ // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
163
+ // one per face reaction (its rate), as many as the busiest face has.
164
+ const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
165
+ const M = n + 1 + nRx;
166
+ const nNodes = grid.nNodes;
167
+ const nFaces = regions.length - 1;
168
+ const nB = nNodes + nFaces;
169
+ this.n = n;
170
+ this.M = M;
171
+ this.nRx = nRx;
172
+ this.nNodes = nNodes;
173
+ this.nFaces = nFaces;
174
+ this.nB = nB;
175
+ this.VT = model.RT / FARADAY; // thermal voltage, V
176
+
177
+
178
+ // Solver block of each grid node and of each interface flux node.
179
+ this.blockOfNode = new Int32Array(nNodes);
180
+ for (let g = 0; g < nNodes; g++) this.blockOfNode[g] = g + grid.nodeRegion[g];
181
+ this.blockOfFace = new Int32Array(nFaces);
182
+ for (let f = 0; f < nFaces; f++) this.blockOfFace[f] = grid.regionEnd[f] + f + 1;
183
+
184
+ // Per-node material data, flattened [g·n + i].
185
+ this.present = new Uint8Array(nNodes * n);
186
+ this.cRef = new Float64Array(nNodes * n);
187
+ this.mu0hat = new Float64Array(nNodes * n); // μ°/RT
188
+ this.rhoFixed = new Float64Array(nNodes);
189
+ this.nodeMaterial = new Int32Array(nNodes);
190
+ this.z = Int32Array.from(species, (s) => s.z);
191
+ for (let g = 0; g < nNodes; g++) {
192
+ const reg = regions[grid.nodeRegion[g]];
193
+ const mat = materials[reg.material];
194
+ this.rhoFixed[g] = reg.fixedCharge;
195
+ this.nodeMaterial[g] = reg.material;
196
+ for (let i = 0; i < n; i++) {
197
+ this.present[g * n + i] = mat.present[i];
198
+ this.cRef[g * n + i] = mat.cRef[i];
199
+ this.mu0hat[g * n + i] = mat.mu0[i] / model.RT;
200
+ }
201
+ }
202
+
203
+ // Regions where φ is undefined: no charged species, and either ε = 0 or nothing couples the
204
+ // region electrostatically (neutral faces, no gate). φ there gets an identity row.
205
+ // φ is undefined in a conductor or an insertion host, and in a charge-free region (no
206
+ // charged species) unless its electrostatic cluster, the regions joined to it by faces that
207
+ // aren't neutral, reaches something that fixes φ: a region with charged species, a gate or
208
+ // pinned contact, or a capacitive face to a conductor. (Else φ there is a free constant.)
209
+ this.phiUndefined = new Uint8Array(nNodes);
210
+ const charged = (mat) => species.some((sp, i) => mat.present[i] && sp.z !== 0);
211
+ const pins = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
212
+ const free = (r) => materials[regions[r].material].phiFree || materials[regions[r].material].epsr === 0;
213
+ const anchored = new Uint8Array(regions.length);
214
+ for (let r0 = 0; r0 < regions.length; r0++) {
215
+ if (free(r0) || anchored[r0]) continue;
216
+ let r1 = r0;
217
+ while (r1 + 1 < regions.length && model.interfaces[r1].phi.type !== 'neutral' && !free(r1 + 1)) r1++;
218
+ let anchor = (r0 === 0 && pins(contacts.left)) || (r1 === regions.length - 1 && pins(contacts.right));
219
+ for (let r = r0; r <= r1; r++) if (charged(materials[regions[r].material])) anchor = true;
220
+ if (r0 > 0 && model.interfaces[r0 - 1].phi.type !== 'neutral' && materials[regions[r0 - 1].material].conductor) anchor = true;
221
+ if (r1 < regions.length - 1 && model.interfaces[r1].phi.type !== 'neutral' && materials[regions[r1 + 1].material].conductor) anchor = true;
222
+ for (let r = r0; r <= r1; r++) anchored[r] = anchor ? 1 : 2;
223
+ r0 = r1;
224
+ }
225
+ regions.forEach((reg, r) => {
226
+ const mat = materials[reg.material];
227
+ if (mat.phiFree || (!charged(mat) && (mat.epsr === 0 || anchored[r] !== 1))) {
228
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) this.phiUndefined[g] = 1;
229
+ }
230
+ });
231
+ // A face with φ undefined on both sides holds no charge, whatever its law says.
232
+ model.interfaces.forEach((itf, f) => {
233
+ if (itf.phi.type !== 'neutral' && this.phiUndefined[grid.regionEnd[f]] && this.phiUndefined[grid.regionEnd[f] + 1]) itf.phi = { type: 'neutral' };
234
+ });
235
+
236
+ // Unknowns as compensated double-doubles, u + uLo. Only differences of η need the extra
237
+ // precision: a majority carrier carrying a small current has a quasi-Fermi step between
238
+ // nodes far below the ulp of η itself (e.g. 1e-19 vs 3.5e-15), and would otherwise carry
239
+ // exactly zero current there. The Jacobian and linear solve stay in plain doubles.
240
+ this.u = new Float64Array(nB * M);
241
+ this.uLo = new Float64Array(nB * M);
242
+ this.uPrev = new Float64Array(nB * M);
243
+ this.uPrevLo = new Float64Array(nB * M);
244
+ this.c = new Float64Array(nNodes * n);
245
+ this.cOld = new Float64Array(nNodes * n);
246
+ // Non-ideal statistics: per node, K = ∂c/∂ζ (n×n) and the excess ζ − ln(c/c_ref), which
247
+ // enters the fluxes as an extra potential. Ideal nodes skip both.
248
+ this.nodeIdeal = new Uint8Array(nNodes);
249
+ this.background = new Float64Array(nNodes);
250
+ for (let g = 0; g < nNodes; g++) {
251
+ const reg = regions[grid.nodeRegion[g]];
252
+ this.nodeIdeal[g] = materials[reg.material].ideal ? 1 : 0;
253
+ this.background[g] = reg.background;
254
+ }
255
+ this.anyNonIdeal = !this.nodeIdeal.every((v) => v === 1);
256
+ // Metal nodes: the carrier's index, and for a node at a charged face of its metal, the face
257
+ // whose displacement is the metal's surface charge (σ = ±D_f), held as a sheet of excess
258
+ // carriers in that node's half-box.
259
+ this.nodeConductor = new Int32Array(nNodes).fill(-1);
260
+ this.sheetFace = new Int32Array(nNodes).fill(-1);
261
+ this.sheetSign = new Int8Array(nNodes);
262
+ // A metal node's φ slot (φ is undefined there) carries instead the carrier flux J through the
263
+ // segment to its right: Ohm's law and continuity in mixed form stay well conditioned however
264
+ // large σ is (eliminating a stiff ohmic chain directly would cancel catastrophically).
265
+ this.conductorLast = new Uint8Array(nNodes);
266
+ for (let g = 0; g < nNodes; g++) {
267
+ const mat = materials[regions[grid.nodeRegion[g]].material];
268
+ if (mat.conductor) {
269
+ this.nodeConductor[g] = mat.conductor.i;
270
+ this.nodeIdeal[g] = 1;
271
+ this.conductorLast[g] = g === grid.regionEnd[grid.nodeRegion[g]] ? 1 : 0;
272
+ }
273
+ }
274
+ model.interfaces.forEach((itf, f) => {
275
+ if (!itf.conductor || itf.conductor.side === 'both' || itf.phi.type === 'neutral') return;
276
+ const g = itf.conductor.side === 'left' ? grid.regionEnd[f] : grid.regionStart[f + 1];
277
+ this.sheetFace[g] = f;
278
+ this.sheetSign[g] = itf.conductor.side === 'left' ? 1 : -1; // σ_metal = D_f on the left, −D_f on the right
279
+ });
280
+ this._activeSlots();
281
+ this.K = this.anyNonIdeal ? new Float64Array(nNodes * n * n) : null;
282
+ this.ex = new Float64Array(nNodes * n);
283
+ this.zeta = new Float64Array(n);
284
+ this.scratch = new Map(); // per statistics model: ζ, c, K work arrays
285
+ this.dA = new Float64Array(M); // derivative work vectors over one block's slots
286
+ this.dB = new Float64Array(M);
287
+ this.jL = new Float64Array(M);
288
+ this.jR = new Float64Array(M);
289
+ this.time = 0;
290
+ this.lastDt = Infinity;
291
+ // Contact bookkeeping, filled by assemble(): particle flux toward +x through each contact,
292
+ // and the displacement there (the metal's surface charge for a neutral link).
293
+ this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
294
+ this.portFlux = model.ports.map(() => new Float64Array(n)); // into the device, mol/(m²·s)
295
+ this.contactD = { left: 0, right: 0 };
296
+ this.contactDOld = { left: 0, right: 0 };
297
+ this.contactDStart = { left: 0, right: 0 };
298
+ // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
299
+ // floating (driven by a current, or behind a resistance), and its current into the device.
300
+ // Sources are read at sourceTime: a step's end, or now for a steady solve.
301
+ this.terms = model.terminals;
302
+ this.termV = new Float64Array(this.terms.length);
303
+ this.termVPrev = new Float64Array(this.terms.length);
304
+ this.termI = new Float64Array(this.terms.length);
305
+ this.sourceTime = 0;
306
+ this.sourceOverride = new Map(); // terminal → voltage, while a steady solve ramps it
307
+ this.steady = false; // inside a steady solve: sources at the present time, not a step's end
308
+ // The floating ones are extra unknowns, solved with the grid's by bordering (_solveBordered).
309
+ this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
310
+ // Per terminal: B = ∂res/∂V (compact column), C = ∂I/∂x (compact row), ∂I/∂V, and for a
311
+ // floating one its circuit residual (I − I_set, or I − (V_src − V)/R).
312
+ this.termB = this.terms.map(() => new Float64Array(this.sys.size + 1));
313
+ this.termC = this.terms.map(() => new Float64Array(this.sys.size + 1));
314
+ this.termDI = new Float64Array(this.terms.length);
315
+ this.termRes = new Float64Array(this.terms.length);
316
+ this._refreshSources();
317
+ // A floating terminal starts level with the first held one (no current).
318
+ const level = this.termV[this.terms.findIndex((_, k) => !this.floating.includes(k))];
319
+ for (const k of this.floating) this.termV[k] = this.terms[k].drive.kind === 'V' ? this.termV[k] : level;
320
+ // Accepted steps, most recent first: start time, size, start state (for BDF2 and the
321
+ // error estimate of adaptive stepping).
322
+ this.history = [];
323
+ this.dtNext = undefined;
324
+
325
+ // Bulk reactions as flat participant lists: reactants with +ν, products with −ν.
326
+ this.rxs = model.reactions.map((rx) => ({
327
+ kf: rx.kf,
328
+ generation: rx.generation,
329
+ fixedA: rx.fixedA,
330
+ sp: Int32Array.from([...rx.reactants, ...rx.products], (p) => p.i),
331
+ nu: Float64Array.from([...rx.reactants.map((p) => p.nu), ...rx.products.map((p) => -p.nu)]),
332
+ }));
333
+
334
+ // Whether any region is strictly neutral (ε = 0, not a conductor): see integrate().
335
+ this.strictlyNeutral = model.regions.some((reg) => {
336
+ const mat = materials[reg.material];
337
+ return !mat.conductor && mat.epsr === 0;
338
+ });
339
+
340
+ // Scales the generation reactions' rates during a steady solve's continuation (else 1).
341
+ this.generationScale = 1;
342
+ this.hasGeneration = this.rxs.some((rx) => rx.generation);
343
+
344
+ // The bulk reactions running in each material.
345
+ this.rxsIn = materials.map((_, m) => this.rxs.filter((rx) => rx.kf[m] > 0));
346
+
347
+ // The boxes whose residuals the bookkeeping reads (contact and port fluxes: the end nodes and
348
+ // every port's window), with the segments and faces that touch them, per region. After each
349
+ // step only these are evaluated (_assembleBookkeeping), not the whole device.
350
+ const need = new Uint8Array(nNodes);
351
+ need[0] = need[nNodes - 1] = 1;
352
+ for (const port of model.ports) for (const g of port.nodes) need[g] = 1;
353
+ // Nodes of strictly neutral (ε = 0) regions that stay neutral, where φ is defined: interior
354
+ // ones, and edges at neutral faces (a capacitive or pinned face's edge node holds the face's
355
+ // charge), away from contacts and ports. On a transient step these are solved in better-
356
+ // conditioned terms (see _chargeRows).
357
+ this.chargeNode = new Uint8Array(nNodes);
358
+ const neutralFace = (f) => f >= 0 && f < model.interfaces.length && model.interfaces[f].phi.type === 'neutral';
359
+ regions.forEach((reg, r) => {
360
+ const mat = materials[reg.material];
361
+ if (mat.conductor || mat.epsr !== 0) return;
362
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
363
+ for (let g = g0; g <= g1; g++) {
364
+ const edgeOk = (g > g0 || neutralFace(r - 1)) && (g < g1 || neutralFace(r));
365
+ if (edgeOk && !need[g] && !this.phiUndefined[g]) this.chargeNode[g] = 1;
366
+ }
367
+ });
368
+ this.chargeNodes = Int32Array.from([...this.chargeNode.keys()].filter((g) => this.chargeNode[g]));
369
+ this.combining = false;
370
+ this.bookkeeping = regions.map((_, r) => {
371
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], nodes = [], segs = [];
372
+ for (let g = g0; g <= g1; g++) if (need[g]) nodes.push(g);
373
+ for (let q = g0; q < g1; q++) if (need[q] || need[q + 1]) segs.push(q);
374
+ return { nodes, segs };
375
+ });
376
+ this.bookkeepingFaces = [];
377
+ for (let f = 0; f < nFaces; f++) if (need[grid.regionEnd[f]] || need[grid.regionStart[f + 1]]) this.bookkeepingFaces.push(f);
378
+
379
+ this._findStretches();
380
+ this.initFromComposition();
381
+ this.referenceAmounts = this.stretches.map((st) => this.amount(st));
382
+ // ∫ (flux in − flux out) dt through the contacts, per stretch, since the reference.
383
+ this.boundaryIntake = new Float64Array(this.stretches.length);
384
+ }
385
+
386
+ // The slots that are unknowns, block by block; the rest only ever have identity rows and a
387
+ // zero residual, so they're left out of the linear system (and never change).
388
+ _activeSlots() {
389
+ const { n, M, nB, model } = this;
390
+ const active = (this.active = new Uint8Array(nB * M));
391
+ for (let g = 0; g < this.nNodes; g++) {
392
+ const b = this.blockOfNode[g], im = this.nodeConductor[g];
393
+ if (im >= 0) {
394
+ active[b * M] = this.conductorLast[g] ? 0 : 1; // the segment flux J
395
+ active[b * M + 1 + im] = 1;
396
+ continue;
397
+ }
398
+ active[b * M] = this.phiUndefined[g] ? 0 : 1;
399
+ for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
400
+ }
401
+ model.interfaces.forEach((itf, f) => {
402
+ const bf = this.blockOfFace[f];
403
+ active[bf * M] = itf.phi.type === 'neutral' ? 0 : 1;
404
+ for (let i = 0; i < n; i++) active[bf * M + 1 + i] = itf.links[i].type === 'blocked' ? 0 : 1;
405
+ for (let k = 0; k < itf.reactions.length; k++) active[bf * M + 1 + n + k] = 1;
406
+ });
407
+ const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
408
+ for (let b = 0; b < nB; b++) for (let r = 0; r < M; r++) if (active[b * M + r]) loc[b * M + r] = sizes[b]++;
409
+ const sys = (this.sys = new BlockTridiagonal(nB, sizes));
410
+ const N = sys.size;
411
+ this.SINK = N;
412
+ const rix = (this.rix = new Int32Array(nB * M).fill(N)), fullOf = (this.fullOf = new Int32Array(N));
413
+ for (let k = 0; k < nB * M; k++) {
414
+ if (loc[k] < 0) continue;
415
+ rix[k] = sys.offX[Math.floor(k / M)] + loc[k];
416
+ fullOf[rix[k]] = k;
417
+ }
418
+ this.res = new Float64Array(N + 1);
419
+ this.delta = new Float64Array(N + 1);
420
+ }
421
+
422
+ _factor() {
423
+ this.sys.factor();
424
+ }
425
+
426
+ // delta = J⁻¹ rhs (compact vectors).
427
+ _solveLinear(rhs, delta) {
428
+ this.sys.solve(rhs, delta);
429
+ }
430
+
431
+ // Connected stretches of regions where a species is present. A stretch not connected to
432
+ // a contact (and, later, with no reaction) is a conserved spectator.
433
+ _findStretches() {
434
+ const { model, n } = this;
435
+ const { regions, materials, contacts, grid } = model;
436
+ const last = regions.length - 1;
437
+ this.stretches = [];
438
+ this.stretchOf = new Int32Array(regions.length * n).fill(-1);
439
+ for (let i = 0; i < n; i++) {
440
+ let r = 0;
441
+ while (r <= last) {
442
+ if (!materials[regions[r].material].present[i]) { r++; continue; }
443
+ const r0 = r;
444
+ while (r + 1 <= last && materials[regions[r + 1].material].present[i] && model.interfaces[r].links[i].type !== 'blocked') r++;
445
+ const touches = (ct) => ct.species[i].type !== 'blocked';
446
+ const leftOpen = r0 === 0 && touches(contacts.left);
447
+ const rightOpen = r === last && touches(contacts.right);
448
+ let reactive = false;
449
+ // made or consumed by a reaction at any face it touches
450
+ for (let f = Math.max(0, r0 - 1); f <= Math.min(model.interfaces.length - 1, r); f++) {
451
+ for (const rx of model.interfaces[f].reactions) {
452
+ if (rx.part.some((p) => p.i === i && f + p.side >= r0 && f + p.side <= r)) reactive = true;
453
+ }
454
+ }
455
+ for (let q = r0; q <= r; q++) {
456
+ this.stretchOf[q * n + i] = this.stretches.length;
457
+ const m = regions[q].material;
458
+ for (const rx of model.reactions) {
459
+ if (rx.kf[m] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
460
+ }
461
+ }
462
+ const ports = model.ports.flatMap((port, k) => (port.region >= r0 && port.region <= r && port.species[i].type !== 'blocked' ? [k] : []));
463
+ const connected = leftOpen || rightOpen || ports.length > 0;
464
+ this.stretches.push({
465
+ ports,
466
+ species: i,
467
+ regions: [r0, r],
468
+ nodes: [grid.regionStart[r0], grid.regionEnd[r]],
469
+ connected,
470
+ reactive,
471
+ // Conserved on its own: not fed by a contact, not made or consumed by a reaction.
472
+ spectator: !connected && !reactive,
473
+ contactFed: connected, // reached directly by a contact
474
+ });
475
+ r++;
476
+ }
477
+ }
478
+ // Which stretches are fed: those in no conserved combination. A conserved combination is a
479
+ // weighting w of the stretches' amounts that no reaction changes (w·ν = 0 for every face and
480
+ // bulk reaction) and that nothing outside feeds (w = 0 on stretches reached by a contact or
481
+ // a port). So Ag⁺ between silver electrodes is fed (Ag⁺ + e⁻ ⇌ Ag(s), the electrons fed by the
482
+ // contacts), while Fe³⁺ and Fe²⁺ between platinum electrodes aren't (Fe³⁺ + e⁻ ⇌ Fe²⁺
483
+ // conserves the iron, whatever the electrons do).
484
+ this.moieties = this._conservedMoieties();
485
+ this.stretches.forEach((st, k) => {
486
+ st.connected = st.contactFed || this.moieties.every((w) => w[k] === 0);
487
+ });
488
+ for (const st of this.stretches) {
489
+ st.spectator = !st.connected && !st.reactive;
490
+ // Mobile throughout: its steady state is then a single level fixed by its amount.
491
+ st.mobile = true;
492
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) if (!(materials[regions[q].material].D[st.species] > 0)) st.mobile = false;
493
+ }
494
+ // Conserved amounts solved directly, each in place of one (redundant) balance row: a
495
+ // spectator's own amount, and each conserved combination of reacting stretches (the total
496
+ // iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose weighted balances sum to zero at steady state.
497
+ // Immobile stretches conserve node by node instead, and a floating conductor holds charge on
498
+ // its faces; both are left to giant time steps.
499
+ this.constraints = [];
500
+ const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
501
+ const add = (parts, rowStretch) => {
502
+ const st = this.stretches[rowStretch];
503
+ const nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
504
+ for (const p of parts) this.stretches[p.stretch].conserved = true;
505
+ this.constraints.push({
506
+ parts,
507
+ row: this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
508
+ idx: new Int32Array(nNodes * this.M),
509
+ w: new Float64Array(nNodes * this.M),
510
+ len: 0,
511
+ res: 0,
512
+ q: new Float64Array(this.sys.size + 1),
513
+ });
514
+ };
515
+ this.stretches.forEach((st, k) => {
516
+ if (st.spectator && st.mobile) add([{ stretch: k, w: 1 }], k);
517
+ });
518
+ for (const w of this.moieties) {
519
+ const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
520
+ if (parts.length === 1 && this.stretches[parts[0].stretch].spectator) continue; // (above)
521
+ if (parts.some((p) => !this.stretches[p.stretch].mobile || conductor(this.stretches[p.stretch]))) continue;
522
+ // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
523
+ add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch);
524
+ }
525
+ this.constrained = false;
526
+ }
527
+
528
+ // A basis of the conserved combinations of stretch amounts: the null space of the
529
+ // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
530
+ // for each stretch fed from outside. Exact integer data, so plain elimination will do.
531
+ _conservedMoieties() {
532
+ const { model, n } = this, S = this.stretches.length;
533
+ const rows = [];
534
+ model.interfaces.forEach((itf, f) => {
535
+ for (const rx of itf.reactions) {
536
+ const row = new Float64Array(S);
537
+ for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
538
+ rows.push(row);
539
+ }
540
+ });
541
+ model.regions.forEach((reg, q) => {
542
+ for (const rx of model.reactions) {
543
+ if (!(rx.kf[reg.material] > 0)) continue;
544
+ const row = new Float64Array(S);
545
+ for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
546
+ for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
547
+ rows.push(row);
548
+ }
549
+ });
550
+ this.stretches.forEach((st, k) => {
551
+ if (!st.contactFed) return;
552
+ const row = new Float64Array(S);
553
+ row[k] = 1;
554
+ rows.push(row);
555
+ });
556
+ // Reduced row echelon form; the free columns give the null space.
557
+ const pivots = [];
558
+ let r = 0;
559
+ for (let col = 0; col < S && r < rows.length; col++) {
560
+ let best = r;
561
+ for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
562
+ if (Math.abs(rows[best][col]) < 1e-9) continue;
563
+ [rows[r], rows[best]] = [rows[best], rows[r]];
564
+ const pr = rows[r], pv = pr[col];
565
+ for (let j = 0; j < S; j++) pr[j] /= pv;
566
+ for (let i = 0; i < rows.length; i++) {
567
+ if (i === r || rows[i][col] === 0) continue;
568
+ const fct = rows[i][col];
569
+ for (let j = 0; j < S; j++) rows[i][j] -= fct * pr[j];
570
+ }
571
+ pivots.push(col);
572
+ r++;
573
+ }
574
+ const isPivot = new Uint8Array(S);
575
+ for (const col of pivots) isPivot[col] = 1;
576
+ const basis = [];
577
+ for (let free = 0; free < S; free++) {
578
+ if (isPivot[free]) continue;
579
+ const w = new Float64Array(S);
580
+ w[free] = 1;
581
+ pivots.forEach((col, i) => {
582
+ const v = -rows[i][free];
583
+ w[col] = Math.abs(v) < 1e-9 ? 0 : v;
584
+ });
585
+ basis.push(w);
586
+ }
587
+ return basis;
588
+ }
589
+
590
+ // Every stretch either reaches a contact or is a mobile spectator: the steady equations can
591
+ // be solved directly (dt = ∞), with the spectators' amounts as constraints.
592
+ _directSteady() {
593
+ return this.stretches.every((st) => st.connected || st.conserved);
594
+ }
595
+
596
+ // Conservation rows for the spectators (steady solves only): Σ v c_i = amount over the
597
+ // stretch replaces the balance row of its first node, which in steady state is the negative
598
+ // sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
599
+ // (identity row) there instead, and _solveBordered restores the constraint.
600
+ _applyConstraints() {
601
+ const { n, M, res, c, dA: d } = this;
602
+ const vol = this.model.grid.vol;
603
+ for (const cs of this.constraints) {
604
+ let amount = 0, reference = 0, len = 0;
605
+ for (const { stretch, w } of cs.parts) {
606
+ const st = this.stretches[stretch], i = st.species;
607
+ reference += w * this.referenceAmounts[stretch];
608
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
609
+ amount += w * vol[g] * c[g * n + i];
610
+ this._dc(g, i, d);
611
+ const b = this.blockOfNode[g];
612
+ for (let r = 0; r < M; r++) {
613
+ if (d[r] === 0 || this.loc[b * M + r] < 0) continue;
614
+ cs.idx[len] = this.rix[b * M + r];
615
+ cs.w[len++] = w * vol[g] * d[r];
616
+ }
617
+ }
618
+ }
619
+ cs.len = len;
620
+ cs.res = amount - reference;
621
+ const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
622
+ this._replaceRow(b0, r0);
623
+ this._j(b0, r0, b0, r0, 1);
624
+ res[this.rix[cs.row]] = 0;
625
+ }
626
+ }
627
+
628
+ // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
629
+ // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
630
+ // the spectators' conservation rows (pinned in T, see _applyConstraints). By low-rank
631
+ // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
632
+ // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
633
+ // system for the μ (pins) and δV (terminals):
634
+ // pin q: Σ (W_q·Q_q′) μ_q′ − Σ (W_q·X_k) δV_k = res_q − W_q·y
635
+ // terminal k: Σ (C_k·Q_q) μ_q + Σ (δ_kk′ ∂I_k/∂V_k − C_k·X_k′) δV_k′ = res_k − C_k·y
636
+ _solveBordered(rhs, delta, deltaV, pins) {
637
+ const N = this.sys.size, fl = this.floating, P = pins.length, K = fl.length, S = P + K;
638
+ this._solveLinear(rhs, delta);
639
+ if (S === 0) return;
640
+ const e = this.dWork ?? (this.dWork = new Float64Array(N + 1));
641
+ const cols = this.borderCols ?? (this.borderCols = []);
642
+ for (let a = 0; a < S; a++) {
643
+ if (!cols[a]) cols[a] = new Float64Array(N + 1);
644
+ if (a < P) {
645
+ const row = this.rix[pins[a].row];
646
+ e[row] = 1;
647
+ this._solveLinear(e, cols[a]);
648
+ e[row] = 0;
649
+ } else this._solveLinear(this.termB[fl[a - P]], cols[a]);
650
+ }
651
+ // Row a of the small system, as a dot product with a compact vector (sparse for pins).
652
+ const dot = (a, v) => {
653
+ let t = 0;
654
+ if (a < P) {
655
+ const p = pins[a];
656
+ for (let j = 0; j < p.len; j++) t += p.w[j] * v[p.idx[j]];
657
+ } else {
658
+ const C = this.termC[fl[a - P]];
659
+ for (let j = 0; j < N; j++) t += C[j] * v[j];
660
+ }
661
+ return t;
662
+ };
663
+ const A = Array.from({ length: S }, () => new Float64Array(S + 1));
664
+ for (let a = 0; a < S; a++) {
665
+ for (let b = 0; b < S; b++) A[a][b] = (b < P ? 1 : -1) * dot(a, cols[b]);
666
+ if (a >= P) A[a][a] += this.termDI[fl[a - P]];
667
+ A[a][S] = (a < P ? pins[a].res : this.termRes[fl[a - P]]) - dot(a, delta);
668
+ let mx = 0;
669
+ for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
670
+ if (mx > 0) for (let b = 0; b <= S; b++) A[a][b] /= mx;
671
+ }
672
+ // Small dense solve with partial pivoting.
673
+ for (let col = 0; col < S; col++) {
674
+ let p = col;
675
+ for (let r = col + 1; r < S; r++) if (Math.abs(A[r][col]) > Math.abs(A[p][col])) p = r;
676
+ [A[col], A[p]] = [A[p], A[col]];
677
+ if (A[col][col] === 0) throw new SolverError('the bordered system is singular: a conserved amount or a floating terminal is not determined');
678
+ for (let r = col + 1; r < S; r++) {
679
+ const f = A[r][col] / A[col][col];
680
+ for (let k = col; k <= S; k++) A[r][k] -= f * A[col][k];
681
+ }
682
+ }
683
+ const x = new Float64Array(S);
684
+ for (let r = S - 1; r >= 0; r--) {
685
+ let v = A[r][S];
686
+ for (let k = r + 1; k < S; k++) v -= A[r][k] * x[k];
687
+ x[r] = v / A[r][r];
688
+ }
689
+ for (let a = 0; a < S; a++) {
690
+ const sa = a < P ? x[a] : -x[a], col = cols[a];
691
+ for (let j = 0; j < N; j++) delta[j] += sa * col[j];
692
+ if (a >= P) deltaV[a - P] = x[a];
693
+ }
694
+ }
695
+
696
+
697
+ /** Total amount (mol per unit area) of a stretch's species in the current state. */
698
+ amount(stretch) {
699
+ const { n, c, model } = this;
700
+ const vol = model.grid.vol;
701
+ let s = 0;
702
+ for (let g = stretch.nodes[0]; g <= stretch.nodes[1]; g++) s += vol[g] * c[g * n + stretch.species];
703
+ return s;
704
+ }
705
+
706
+ // After the terminals' drives change (Device.set): which are floating, and the held values. A
707
+ // change of source is a discontinuity, so time stepping restarts its order.
708
+ redrive() {
709
+ const before = this.floating;
710
+ this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
711
+ if (this.floating.length !== before.length) this.deltaV = null;
712
+ this._refreshSources();
713
+ this.history = [];
714
+ }
715
+
716
+ // Held terminals take their sources' values at sourceTime (a floating one keeps its voltage,
717
+ // an unknown; one behind a resistance reads its source voltage in _circuit).
718
+ _refreshSources() {
719
+ this.terms.forEach((t, k) => {
720
+ if (t.drive.kind !== 'V' || t.drive.R > 0) return;
721
+ this.termV[k] = this.sourceOverride.has(k) ? this.sourceOverride.get(k) : sourceAt(t.drive.src, this.sourceTime, this.sourceBefore);
722
+ });
723
+ }
724
+
725
+ /** η_i (μ̄/RT) of a port's outside level for species i. */
726
+ portEta(port, i) {
727
+ const link = port.species[i];
728
+ const V = this.termV[2 + this.model.ports.indexOf(port)];
729
+ return this.z[i] === 0 ? link.mu / this.model.RT : (this.z[i] * (V + link.offset)) / this.VT;
730
+ }
731
+
732
+ /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
733
+ contactEta(side, i) {
734
+ const link = this.model.contacts[side].species[i];
735
+ if (link.type !== 'equilibrium') return NaN;
736
+ const z = this.z[i];
737
+ return z === 0 ? link.mu / this.model.RT : (z * (this.termV[side === 'left' ? 0 : 1] + link.offset)) / this.VT;
738
+ }
739
+
740
+ /**
741
+ * Cold start. Species connected to a contact take that contact's level; spectators take
742
+ * their region's c0 (which fixes their conserved amount). φ in each region is then chosen
743
+ * for local neutrality, or continued across the interface dipole if nothing there responds.
744
+ */
745
+ initFromComposition() {
746
+ const { model, n, M, u, z } = this;
747
+ const { regions, species, interfaces, materials } = model;
748
+ const grid = model.grid;
749
+ u.fill(0);
750
+ this.uLo.fill(0);
751
+ const eta = new Float64Array(n), cFix = new Float64Array(n), mode = new Int8Array(n); // 1 level, 2 amount
752
+ let phiHat = 0;
753
+ for (let r = 0; r < regions.length; r++) {
754
+ if (r > 0) phiHat += interfaces[r - 1].dipole / this.VT;
755
+ const reg = regions[r], mat = materials[reg.material];
756
+ mode.fill(0);
757
+ for (let i = 0; i < n; i++) {
758
+ if (!mat.present[i]) continue;
759
+ const st = this.stretches[this.stretchOf[r * n + i]];
760
+ if (!st.contactFed && mat.conductor) {
761
+ // A conductor away from the contacts: start uncharged, with its carrier's level in
762
+ // equilibrium with the first reaction on its left face that takes it, else at the
763
+ // running φ.
764
+ eta[i] = z[i] * phiHat;
765
+ const rx = r > 0 ? interfaces[r - 1].reactions.find((x) => x.part.some((p) => p.side === 1 && p.i === i)) : undefined;
766
+ if (rx) {
767
+ const bn = this.blockOfNode[grid.regionEnd[r - 1]];
768
+ let a = rx.fixedA, nu = 0; // a = fixedA − Σ ν η = 0, solved for the carrier's η
769
+ for (const p of rx.part) {
770
+ if (p.side === 1) nu += p.nu;
771
+ else a -= p.nu * u[bn * M + 1 + p.i];
772
+ }
773
+ eta[i] = a / nu;
774
+ }
775
+ mode[i] = 1;
776
+ } else if (!st.contactFed) {
777
+ if (!(reg.c0[i] > 0)) {
778
+ throw new SolverError(
779
+ `regions[${r}].c0.${species[i].name}: a species that doesn't reach a contact needs its initial concentration`,
780
+ );
781
+ }
782
+ mode[i] = 2;
783
+ cFix[i] = reg.c0[i];
784
+ } else {
785
+ const left = st.regions[0] === 0 ? this.contactEta('left', i) : NaN;
786
+ eta[i] = Number.isFinite(left) ? left : this.contactEta('right', i);
787
+ if (!Number.isFinite(eta[i]) && st.ports.length > 0) eta[i] = this.portEta(model.ports[st.ports[0]], i);
788
+ mode[i] = 1;
789
+ if (!Number.isFinite(eta[i])) {
790
+ // Fed only through reactions (no level held at a contact): start from c0.
791
+ if (!(reg.c0[i] > 0)) {
792
+ throw new SolverError(
793
+ `regions[${r}].c0.${species[i].name}: a species fed only through reactions needs its initial concentration`,
794
+ );
795
+ }
796
+ mode[i] = 2;
797
+ cFix[i] = reg.c0[i];
798
+ }
799
+ }
800
+ }
801
+ // Net charge (mol/m³) at trial φ̂; decreasing in φ̂ wherever a level-fixed ion responds.
802
+ const zeta = new Float64Array(n), cc = new Float64Array(n);
803
+ const charge = (ph) => {
804
+ if (!mat.ideal) {
805
+ this._materialAt(mat, reg.background, eta, mode, cFix, ph, zeta, cc);
806
+ let q = reg.fixedCharge / FARADAY;
807
+ for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
808
+ return q;
809
+ }
810
+ let q = reg.fixedCharge / FARADAY;
811
+ for (let i = 0; i < n; i++) {
812
+ if (mode[i] === 2) q += z[i] * cFix[i];
813
+ else if (mode[i] === 1 && z[i] !== 0) {
814
+ const ex = Math.min(700, Math.max(-700, eta[i] - mat.mu0[i] / model.RT - z[i] * ph));
815
+ q += z[i] * mat.cRef[i] * Math.exp(ex);
816
+ }
817
+ }
818
+ return q;
819
+ };
820
+ const responds = mode.some((m, i) => m === 1 && z[i] !== 0);
821
+ if (!responds && !mat.conductor && !mat.phiFree && mat.epsr === 0) {
822
+ // Strictly neutral, with every charged species' amount given: its c0 must be neutral.
823
+ let q = reg.fixedCharge / FARADAY, scale = Math.abs(q);
824
+ for (let i = 0; i < n; i++) {
825
+ if (mode[i] !== 2 || z[i] === 0) continue;
826
+ q += z[i] * cFix[i];
827
+ scale += Math.abs(z[i] * cFix[i]);
828
+ }
829
+ if (Math.abs(q) > 1e-9 * scale) {
830
+ throw new SolverError(
831
+ `regions[${r}] (${reg.name}): its initial composition carries a net charge of ${q.toPrecision(3)} mol/m³ (Σ z·c0, with any fixed charge), ` +
832
+ 'but ε = 0 makes it strictly neutral; adjust c0 so that it balances',
833
+ );
834
+ }
835
+ }
836
+ if (!mat.phiFree && !responds && mat.ideal && r > 0 && materials[regions[r - 1].material].conductor) {
837
+ // Nothing here fixes φ, but an electrode on the left does: start with its first reaction
838
+ // that takes a species from this side at equilibrium (the electrode at its open-circuit
839
+ // level), rather than φ carried over from the metal, which can be volts away.
840
+ const bn = this.blockOfNode[grid.regionEnd[r - 1]];
841
+ for (const rx of interfaces[r - 1].reactions) {
842
+ let a = rx.fixedA, s = 0;
843
+ for (const p of rx.part) {
844
+ if (p.side === 0) a -= p.nu * u[bn * M + 1 + p.i];
845
+ else if (mode[p.i] === 2) {
846
+ a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
847
+ s += p.nu * z[p.i];
848
+ } else s = NaN;
849
+ }
850
+ if (s !== 0 && Number.isFinite(s)) {
851
+ phiHat = a / s; // a − s·φ̂ = 0
852
+ break;
853
+ }
854
+ }
855
+ }
856
+ if (!mat.phiFree && responds) {
857
+ let lo = phiHat - 1, hi = phiHat + 1;
858
+ while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
859
+ while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
860
+ for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
861
+ const m = 0.5 * (lo + hi);
862
+ if (charge(m) > 0) lo = m;
863
+ else hi = m;
864
+ }
865
+ phiHat = 0.5 * (lo + hi);
866
+ }
867
+ if (!mat.ideal) this._materialAt(mat, reg.background, eta, mode, cFix, phiHat, zeta, cc);
868
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
869
+ const b = this.blockOfNode[g];
870
+ u[b * M] = mat.conductor ? 0 : phiHat; // a metal's slot 0 is its segment flux
871
+ for (let i = 0; i < n; i++) {
872
+ if (mode[i] === 1) u[b * M + 1 + i] = eta[i];
873
+ else if (mode[i] === 2) {
874
+ const zt = mat.ideal ? Math.log(cFix[i] / mat.cRef[i]) : zeta[i];
875
+ u[b * M + 1 + i] = zt + mat.mu0[i] / model.RT + z[i] * phiHat;
876
+ }
877
+ }
878
+ }
879
+ }
880
+ this.computeConcentrations();
881
+ }
882
+
883
+ // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
884
+ // amount-fixed ones (mode 2) at cFix. Fills ζ and c for every present species.
885
+ _materialAt(mat, background, eta, mode, cFix, ph, zeta, cc) {
886
+ const { n, z } = this;
887
+ const RT = this.model.RT;
888
+ for (let i = 0; i < n; i++) {
889
+ if (!mat.present[i]) continue;
890
+ zeta[i] = mode[i] === 2 ? Math.log(cFix[i] / mat.cRef[i]) : eta[i] - mat.mu0[i] / RT - z[i] * ph;
891
+ if (mat.modelOf[i] < 0) cc[i] = mode[i] === 2 ? cFix[i] : mat.cRef[i] * Math.exp(Math.min(700, zeta[i]));
892
+ }
893
+ for (const md of mat.models) {
894
+ const w = this._work(md), idx = md.idx, k = idx.length;
895
+ for (let a = 0; a < k; a++) {
896
+ w.z[a] = zeta[idx[a]];
897
+ w.fixed[a] = mode[idx[a]] === 2 ? 1 : 0;
898
+ w.t[a] = cFix[idx[a]];
899
+ }
900
+ md.invert(w.z, w.fixed, w.t, background);
901
+ md.evaluate(w.z, w.c, w.K, background);
902
+ for (let a = 0; a < k; a++) {
903
+ zeta[idx[a]] = w.z[a];
904
+ cc[idx[a]] = w.c[a];
905
+ }
906
+ }
907
+ }
908
+
909
+ computeConcentrations() {
910
+ const { n, M, u, uLo, c, z } = this;
911
+ for (let g = 0; g < this.nNodes; g++) {
912
+ const b = this.blockOfNode[g];
913
+ const phiHat = u[b * M];
914
+ if (!this.nodeIdeal[g]) {
915
+ this._nodeStatistics(g, b, phiHat);
916
+ continue;
917
+ }
918
+ const im = this.nodeConductor[g];
919
+ if (im >= 0) {
920
+ // Excess carriers: zero in the bulk, the surface sheet at a charged face.
921
+ for (let i = 0; i < n; i++) c[g * n + i] = 0;
922
+ const f = this.sheetFace[g];
923
+ if (f >= 0) c[g * n + im] = (this.sheetSign[g] * u[this.blockOfFace[f] * M]) / (z[im] * FARADAY * this.model.grid.vol[g]);
924
+ continue;
925
+ }
926
+ for (let i = 0; i < n; i++) {
927
+ const k = g * n + i;
928
+ 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;
929
+ }
930
+ }
931
+ }
932
+
933
+ _work(md) {
934
+ let w = this.scratch.get(md);
935
+ if (!w) {
936
+ const k = md.idx.length;
937
+ w = { z: new Float64Array(k), c: new Float64Array(k), K: new Float64Array(k * k), fixed: new Uint8Array(k), t: new Float64Array(k) };
938
+ this.scratch.set(md, w);
939
+ }
940
+ return w;
941
+ }
942
+
943
+ // c, K and the excess at a node of a non-ideal material.
944
+ _nodeStatistics(g, b, phiHat) {
945
+ const { n, M, u, uLo, c, z, K, ex, zeta } = this;
946
+ const mat = this.model.materials[this.nodeMaterial[g]];
947
+ const Kg = g * n * n;
948
+ K.fill(0, Kg, Kg + n * n);
949
+ for (let i = 0; i < n; i++) {
950
+ const k = g * n + i;
951
+ ex[k] = 0;
952
+ if (!this.present[k]) {
953
+ c[k] = 0;
954
+ continue;
955
+ }
956
+ zeta[i] = u[b * M + 1 + i] + uLo[b * M + 1 + i] - this.mu0hat[k] - z[i] * phiHat;
957
+ if (mat.modelOf[i] < 0) {
958
+ c[k] = this.cRef[k] * Math.exp(zeta[i]);
959
+ K[Kg + i * n + i] = c[k];
960
+ }
961
+ }
962
+ for (const md of mat.models) {
963
+ const w = this._work(md), idx = md.idx, k = idx.length;
964
+ for (let a = 0; a < k; a++) w.z[a] = zeta[idx[a]];
965
+ md.evaluate(w.z, w.c, w.K, this.background[g]);
966
+ for (let a = 0; a < k; a++) {
967
+ const i = idx[a], ci = w.c[a];
968
+ c[g * n + i] = ci;
969
+ ex[g * n + i] = ci > 0 ? zeta[i] - Math.log(ci / this.cRef[g * n + i]) : 0;
970
+ for (let q = 0; q < k; q++) K[Kg + i * n + idx[q]] = w.K[a * k + q];
971
+ }
972
+ }
973
+ }
974
+
975
+ // Σ_j K_ij z_j at node g (= z_i c_i for ideal statistics).
976
+ _Kz(g, i) {
977
+ const { n, z } = this;
978
+ if (this.nodeIdeal[g]) return z[i] * this.c[g * n + i];
979
+ let s = 0;
980
+ const o = g * n * n + i * n;
981
+ for (let j = 0; j < n; j++) s += this.K[o + j] * z[j];
982
+ return s;
983
+ }
984
+
985
+ /** zᵀKz at node g (per RT): the charge response that sets the screening length. */
986
+ screening(g) {
987
+ let s = 0;
988
+ for (let i = 0; i < this.n; i++) s += this.z[i] * this._Kz(g, i);
989
+ return s;
990
+ }
991
+
992
+ // Add w·∂(ln c_i)/∂(slot) at node g into d (slot 0 = φ̂, 1 + j = η_j).
993
+ _dlnc(g, i, w, d) {
994
+ const { n, z } = this;
995
+ if (this.nodeIdeal[g]) {
996
+ d[1 + i] += w;
997
+ d[0] -= w * z[i];
998
+ return;
999
+ }
1000
+ const ci = this.c[g * n + i];
1001
+ if (!(ci > 0)) return;
1002
+ const o = g * n * n + i * n, f = w / ci;
1003
+ for (let j = 0; j < n; j++) d[1 + j] += f * this.K[o + j];
1004
+ d[0] -= f * this._Kz(g, i);
1005
+ }
1006
+
1007
+ // Add ∂(row rb, slot rs)/∂(block cb, slot cs) to the Jacobian (nothing if either slot isn't
1008
+ // an unknown).
1009
+ _j(rb, rs, cb, cs, v) {
1010
+ const M = this.M, r = this.loc[rb * M + rs], c = this.loc[cb * M + cs];
1011
+ if (r < 0 || c < 0) return;
1012
+ const sys = this.sys, sz = sys.sizes;
1013
+ if (cb === rb) sys.B[sys.offB[rb] + r * sz[rb] + c] += v;
1014
+ else if (cb === rb - 1) sys.A[sys.offA[rb] + r * sz[cb] + c] += v;
1015
+ else if (cb === rb + 1) sys.C[sys.offC[rb] + r * sz[cb] + c] += v;
1016
+ else throw new Error(`internal: non-tridiagonal coupling ${rb}→${cb}`);
1017
+ }
1018
+
1019
+ /** Assemble residual and Jacobian for a backward-Euler step of size dt. */
1020
+ assemble(dt) {
1021
+ const { model, res, sys } = this;
1022
+ const { grid, materials, regions } = model;
1023
+ sys.clear();
1024
+ res.fill(0);
1025
+ this.computeConcentrations();
1026
+
1027
+ // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
1028
+ // dilute region (ideal statistics, the fast path) or a concentrated one (any statistics).
1029
+ for (let r = 0; r < regions.length; r++) {
1030
+ const reg = regions[r], mat = materials[reg.material];
1031
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
1032
+ if (mat.conductor) {
1033
+ for (let g = g0; g <= g1; g++) this._nodeConductor(g, dt);
1034
+ for (let s = g0; s < g1; s++) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s]);
1035
+ continue;
1036
+ }
1037
+ if (mat.ideal) this._nodesDilute(g0, g1, dt);
1038
+ else for (let g = g0; g <= g1; g++) this._nodeConcentrated(g, dt);
1039
+ const rxs = this.rxsIn[reg.material];
1040
+ if (rxs.length > 0) for (let g = g0; g <= g1; g++) this._bulkReactions(g, rxs);
1041
+ for (let s = g0; s < g1; s++) {
1042
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s];
1043
+ this._segmentDisplacement(s, bL, bR, mat, h);
1044
+ if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
1045
+ if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
1046
+ }
1047
+ if (mat.ideal) this._segmentsDilute(g0, g1, r, mat, reg.velocity);
1048
+ }
1049
+
1050
+ // Faces: the flux node carries D, the linked species' fluxes and the reaction rates.
1051
+ for (let f = 0; f < this.nFaces; f++) this._face(f);
1052
+
1053
+ // Terminals: ports (after every other term at their nodes, so a held level can read its
1054
+ // flux), then contacts, then the circuit rows of the floating ones.
1055
+ this._terminals(dt);
1056
+ if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1057
+ this.transformed = this.combining && dt !== Infinity;
1058
+ if (this.transformed) this._chargeRows(dt);
1059
+ }
1060
+
1061
+ // A conductor node: only its carrier's balance (the segment flux J has its own row, in
1062
+ // _segmentConductor). Its bulk stores nothing; a charged face's sheet sits in the edge node.
1063
+ _nodeConductor(g, dt) {
1064
+ const { n, M, res, c, cOld, z } = this, R = this.rix;
1065
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g], im = this.nodeConductor[g];
1066
+ const r = 1 + im, k = g * n + im;
1067
+ res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1068
+ const f = this.sheetFace[g];
1069
+ if (f >= 0) this._j(b, r, this.blockOfFace[f], 0, this.sheetSign[g] / (z[im] * FARADAY * dt));
1070
+ }
1071
+
1072
+ // Dilute nodes g0…g1 (ideal statistics, c = c_ref e^ζ): storage of each species and the space
1073
+ // charge in the Gauss row, written straight into the diagonal blocks by local index. A
1074
+ // dielectric is the case with no species.
1075
+ _nodesDilute(g0, g1, dt) {
1076
+ const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode } = this, R = this.rix, F = FARADAY;
1077
+ const JB = sys.B, sizes = sys.sizes, offB = sys.offB, vol = this.model.grid.vol;
1078
+ for (let g = g0; g <= g1; g++) {
1079
+ const b = blockOfNode[g], v = vol[g];
1080
+ const m = sizes[b], oB = offB[b], lb = b * M, p = loc[lb]; // p: φ's row, −1 where undefined
1081
+ if (this.combining && this.chargeNode[g] === 1 && dt !== Infinity) {
1082
+ // Storage and neutrality go in later, in _chargeRows; a neutral species' storage here.
1083
+ for (let i = 0; i < n; i++) if (present[g * n + i] && z[i] === 0) this._storage(g, i, dt);
1084
+ continue;
1085
+ }
1086
+ let q = rhoFixed[g], dq = 0;
1087
+ for (let i = 0; i < n; i++) {
1088
+ const k = g * n + i;
1089
+ if (!present[k]) continue;
1090
+ const ck = c[k], l = loc[lb + 1 + i];
1091
+ res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
1092
+ JB[oB + l * m + l] += (v * ck) / dt;
1093
+ q += F * z[i] * ck;
1094
+ dq += F * z[i] * z[i] * ck;
1095
+ if (p >= 0 && z[i] !== 0) {
1096
+ JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
1097
+ JB[oB + p * m + l] += -v * F * z[i] * ck;
1098
+ }
1099
+ }
1100
+ if (p >= 0) {
1101
+ res[R[lb]] -= v * q;
1102
+ JB[oB + p * m + p] += v * dq;
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ // A concentrated node (any statistics): ∂c_i/∂η_j = K_ij and ∂c_i/∂φ̂ = −(Kz)_i.
1108
+ _nodeConcentrated(g, dt) {
1109
+ const { n, M, res, c, z } = this, R = this.rix, F = FARADAY;
1110
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g];
1111
+ if (this.combining && this.chargeNode[g] === 1 && dt !== Infinity) {
1112
+ // Storage and neutrality go in later, in _chargeRows; a neutral species' storage here.
1113
+ for (let i = 0; i < n; i++) if (this.present[g * n + i] && z[i] === 0) this._storage(g, i, dt);
1114
+ return;
1115
+ }
1116
+ let q = this.rhoFixed[g], dq = 0;
1117
+ for (let i = 0; i < n; i++) {
1118
+ const k = g * n + i, r = 1 + i;
1119
+ if (!this.present[k]) continue;
1120
+ const Kz = this._Kz(g, i);
1121
+ this._storage(g, i, dt, Kz);
1122
+ q += F * z[i] * c[k];
1123
+ dq += F * z[i] * Kz;
1124
+ if (Kz !== 0) this._j(b, 0, b, r, -v * F * Kz); // K symmetric: ∂(Σ z c)/∂η_i = (Kz)_i
1125
+ }
1126
+ // (where no charge responds and no field reaches, φ isn't defined, nor an unknown)
1127
+ if (!this.phiUndefined[g]) {
1128
+ res[R[b * M]] -= v * q;
1129
+ this._j(b, 0, b, 0, v * dq);
1130
+ }
1131
+ }
1132
+
1133
+ // Species i's storage at node g, v(c − c_old)/dt, in its balance row and the Jacobian
1134
+ // (∂c_i/∂η_j = K_ij, ∂c_i/∂φ̂ = −(Kz)_i; for ideal statistics K is diagonal, c_i).
1135
+ _storage(g, i, dt, Kz = this._Kz(g, i)) {
1136
+ const { n, M, res, c, cOld } = this, R = this.rix;
1137
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g], k = g * n + i, r = 1 + i;
1138
+ res[R[b * M + r]] += (v * (c[k] - cOld[k])) / dt;
1139
+ if (this.nodeIdeal[g]) this._j(b, r, b, r, (v * c[k]) / dt);
1140
+ else {
1141
+ const Kg = g * n * n;
1142
+ for (let j = 0; j < n; j++) {
1143
+ const Kij = this.K[Kg + i * n + j];
1144
+ if (Kij !== 0) this._j(b, r, b, 1 + j, (v * Kij) / dt);
1145
+ }
1146
+ }
1147
+ if (Kz !== 0 && !this.phiUndefined[g]) this._j(b, r, b, 0, (-v * Kz) / dt);
1148
+ }
1149
+
1150
+ // Strictly neutral nodes on a transient step, solved in better-conditioned terms. There, a
1151
+ // change of φ̂ with every η_i shifted by z_i times it leaves every concentration as it was, so
1152
+ // storage and neutrality don't see it; but in (φ̂, η) they see it as pairs of huge entries that
1153
+ // cancel, and round-off in that cancellation (storage/flux ~ h²/(D·dt), times the range of
1154
+ // concentrations: a trace ion beside 3 M KCl) swamps the fluxes that do fix it. So, at each node
1155
+ // that stays neutral (see chargeNode), with storage and neutrality left out of assembly:
1156
+ // - rows: the balance of the most abundant charged species (by z²c) becomes Σ (z_i/z_k) × each
1157
+ // balance, which without storage is current continuity;
1158
+ // - columns: the unknowns become φ̂' and η'_i = η_i − z_i φ̂ (newton() maps the update back),
1159
+ // in which storage and neutrality have no φ̂' term at all;
1160
+ // - then storage (on the other balances) and neutrality go in, exactly, in those terms.
1161
+ // A start-of-step net charge (round-off in a solved state) isn't carried over: the neutrality
1162
+ // row holds the new state neutral.
1163
+ _chargeRows(dt) {
1164
+ const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY;
1165
+ const { A, B, C, sizes, offA, offB, offC } = sys, vol = this.model.grid.vol, last = this.nB - 1;
1166
+ for (const g of this.chargeNodes) {
1167
+ 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;
1168
+ const p = loc[lb], gn = g * n;
1169
+ let k = -1, best = -1;
1170
+ for (let i = 0; i < n; i++) {
1171
+ if (!present[gn + i] || z[i] === 0) continue;
1172
+ const w = z[i] * z[i] * c[gn + i];
1173
+ if (w > best) {
1174
+ k = i;
1175
+ best = w;
1176
+ }
1177
+ }
1178
+ if (k < 0 || p < 0) continue;
1179
+ // Rows: the pivot's balance becomes the z-weighted sum (all still without storage).
1180
+ const lk = loc[lb + 1 + k], rk = R[lb + 1 + k];
1181
+ for (let i = 0; i < n; i++) {
1182
+ if (i === k || !present[gn + i] || z[i] === 0) continue;
1183
+ const li = loc[lb + 1 + i], w = z[i] / z[k], ri = R[lb + 1 + i];
1184
+ for (let q = 0, o = offA[b] + lk * mp, s = offA[b] + li * mp; q < mp; q++) A[o + q] += w * A[s + q];
1185
+ for (let q = 0, o = offB[b] + lk * m, s = offB[b] + li * m; q < m; q++) B[o + q] += w * B[s + q];
1186
+ for (let q = 0, o = offC[b] + lk * mn, s = offC[b] + li * mn; q < mn; q++) C[o + q] += w * C[s + q];
1187
+ res[rk] += w * res[ri];
1188
+ for (let t = 0; t < termB.length; t++) termB[t][rk] += w * termB[t][ri];
1189
+ }
1190
+ // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
1191
+ // unknowns appear (its own rows, its neighbours', a terminal's current).
1192
+ for (let i = 0; i < n; i++) {
1193
+ if (!present[gn + i] || z[i] === 0) continue;
1194
+ const li = loc[lb + 1 + i], zi = z[i];
1195
+ for (let r = 0, o = offB[b]; r < m; r++) B[o + r * m + p] += zi * B[o + r * m + li];
1196
+ 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];
1197
+ 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];
1198
+ for (let t = 0; t < termC.length; t++) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1199
+ }
1200
+ // Storage on the other charged balances, and neutrality: no φ̂' terms, exactly.
1201
+ const v = vol[g], oB = offB[b];
1202
+ let q = this.rhoFixed[g];
1203
+ if (this.nodeIdeal[g]) {
1204
+ for (let i = 0; i < n; i++) {
1205
+ if (!present[gn + i] || z[i] === 0) continue;
1206
+ const ci = c[gn + i], li = loc[lb + 1 + i];
1207
+ if (i !== k) {
1208
+ res[R[lb + 1 + i]] += (v * (ci - cOld[gn + i])) / dt;
1209
+ B[oB + li * m + li] += (v * ci) / dt;
1210
+ }
1211
+ q += F * z[i] * ci;
1212
+ B[oB + p * m + li] += -v * F * z[i] * ci;
1213
+ }
1214
+ } else {
1215
+ for (let i = 0; i < n; i++) {
1216
+ if (!present[gn + i]) continue;
1217
+ if (i !== k && z[i] !== 0) this._storage(g, i, dt, 0);
1218
+ q += F * z[i] * c[gn + i];
1219
+ const Kz = this._Kz(g, i); // (nonzero for a neutral species only on a shared lattice)
1220
+ if (Kz !== 0) this._j(b, 0, b, 1 + i, -v * F * Kz);
1221
+ }
1222
+ }
1223
+ res[R[lb]] -= v * q;
1224
+ }
1225
+ }
1226
+
1227
+ // Newton's update in the transformed unknowns (see _chargeRows) back in η: η_i = η'_i + z_i φ̂'.
1228
+ _untransform(delta) {
1229
+ const { n, M, z, loc, present } = this, R = this.rix;
1230
+ for (const g of this.chargeNodes) {
1231
+ const lb = this.blockOfNode[g] * M;
1232
+ if (loc[lb] < 0) continue;
1233
+ const dphi = delta[R[lb]];
1234
+ for (let i = 0; i < n; i++) if (present[g * n + i] && z[i] !== 0) delta[R[lb + 1 + i]] += z[i] * dphi;
1235
+ }
1236
+ }
1237
+
1238
+ // Bulk reactions at node g: r = k_f Π c_R^ν · (−expm1(−a)), with a = A/RT from the
1239
+ // (compensated) η. Reactants are consumed (+v·ν·r in their balance), products made (−v·ν·r).
1240
+ _bulkReactions(g, rxs) {
1241
+ const { n, M, u, uLo, res, c } = this, R = this.rix;
1242
+ const b = this.blockOfNode[g], v = this.model.grid.vol[g], m = this.nodeMaterial[g];
1243
+ const dr = this.dA;
1244
+ for (let x = 0; x < rxs.length; x++) {
1245
+ const rx = rxs[x];
1246
+ let P = rx.generation ? rx.kf[m] * this.generationScale : rx.kf[m], aHi = rx.fixedA, aLo = 0;
1247
+ dr.fill(0); // ∂ ln P / ∂slot
1248
+ // Participants: reactants with ν > 0, then products with ν < 0.
1249
+ for (let p = 0; p < rx.sp.length; p++) {
1250
+ const i = rx.sp[p], nu = rx.nu[p];
1251
+ if (nu > 0) {
1252
+ const ci = c[g * n + i];
1253
+ P *= nu === 1 ? ci : ci ** nu;
1254
+ this._dlnc(g, i, nu, dr);
1255
+ }
1256
+ aHi += nu * u[b * M + 1 + i];
1257
+ aLo += nu * uLo[b * M + 1 + i];
1258
+ }
1259
+ const a = aHi + aLo;
1260
+ const f = -Math.expm1(-a); // 1 − e^{−a}
1261
+ const rate = P * f;
1262
+ const Pd = P * (1 - f); // P·df/da, df/da = e^{−a}
1263
+ // ∂rate/∂slot = rate·∂lnP + P·f′·∂a
1264
+ for (let k = 0; k < M; k++) dr[k] *= rate;
1265
+ for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Pd * rx.nu[p];
1266
+ for (let p = 0; p < rx.sp.length; p++) {
1267
+ const row = 1 + rx.sp[p], w = v * rx.nu[p];
1268
+ res[R[b * M + row]] += w * rate;
1269
+ for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
+ // Displacement along a segment where φ is defined: D = −ε(φ_R − φ_L)/h into the two Gauss rows.
1275
+ _segmentDisplacement(s, bL, bR, mat, h) {
1276
+ const { M, u, res, sys, loc } = this, R = this.rix;
1277
+ const k = this.phiUndefined[s] ? 0 : (mat.epsr * EPS0 * this.VT) / h; // ε = 0: no displacement
1278
+ if (k === 0) return;
1279
+ const D = -k * (u[bR * M] - u[bL * M]);
1280
+ const mL = sys.sizes[bL], mR = sys.sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1281
+ res[R[bL * M]] += D;
1282
+ res[R[bR * M]] -= D;
1283
+ sys.B[sys.offB[bL] + pL * mL + pL] += k;
1284
+ sys.C[sys.offC[bL] + pL * mR + pR] -= k;
1285
+ sys.A[sys.offA[bR] + pR * mL + pL] -= k;
1286
+ sys.B[sys.offB[bR] + pR * mR + pR] += k;
1287
+ }
1288
+
1289
+ // Scharfetter–Gummel fluxes along a dilute region's segments (nodes g0…g1), written
1290
+ // by local index. The flux
1291
+ // N = g[B(Δ)c_L − B(−Δ)c_R] is rewritten with B(−Δ) = B(Δ)e^Δ and c_R e^Δ = c_L e^{Δη} as
1292
+ // N = −g·B(Δ)·c_L·expm1(Δη). This is precise relative to the quasi-Fermi difference Δη, so tiny
1293
+ // fluxes (e.g. majority carriers carrying a small current) don't vanish in the cancellation of
1294
+ // two huge drift and diffusion terms.
1295
+ _segmentsDilute(g0, g1, region, mat, vel) {
1296
+ const { n, M, u, uLo, res, c, z, sys, loc } = this, R = this.rix, F = FARADAY;
1297
+ const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1298
+ const segLength = this.model.grid.segLength;
1299
+ for (let s = g0; s < g1; s++) {
1300
+ const bL = s + region, bR = bL + 1, h = segLength[s];
1301
+ const mL = sizes[bL], mR = sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1302
+ const phiL = u[bL * M], phiR = u[bR * M];
1303
+ for (let i = 0; i < n; i++) {
1304
+ if (!mat.present[i] || mat.D[i] === 0) continue;
1305
+ const r = 1 + i, zi = z[i];
1306
+ const cL = c[s * n + i];
1307
+ const g = mat.D[i] / h;
1308
+ const pe = (vel * h) / mat.D[i]; // advection: a Péclet shift of the drift potential
1309
+ const d = zi * (phiR - phiL) - pe;
1310
+ const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
1311
+ const E = Math.expm1(deta);
1312
+ const gBc = g * bernoulli(d) * cL;
1313
+ const N = -gBc * E;
1314
+ const dNdd = -g * bernoulliDerivative(d) * cL * E;
1315
+ const dNdEtaL = gBc;
1316
+ const dNdEtaR = -gBc * (E + 1);
1317
+ const dNdPhiL = -zi * dNdd + zi * gBc * E; // via Δ, and via c_L ∝ e^{−zφ̂_L}
1318
+ const dNdPhiR = zi * dNdd;
1319
+ res[R[bL * M + r]] += N;
1320
+ res[R[bR * M + r]] -= N;
1321
+ // Row r of block bL (couplings to itself in B, to bR in C) and of bR (to bL in A).
1322
+ const lL = loc[bL * M + r], lR = loc[bR * M + r];
1323
+ const oBL = offB[bL] + lL * mL, oCL = offC[bL] + lL * mR, oAR = offA[bR] + lR * mL, oBR = offB[bR] + lR * mR;
1324
+ JB[oBL + lL] += dNdEtaL;
1325
+ JC[oCL + lR] += dNdEtaR;
1326
+ JA[oAR + lL] -= dNdEtaL;
1327
+ JB[oBR + lR] -= dNdEtaR;
1328
+ if (zi !== 0 && pL >= 0) {
1329
+ JB[oBL + pL] += dNdPhiL;
1330
+ JC[oCL + pR] += dNdPhiR;
1331
+ JA[oAR + pL] -= dNdPhiL;
1332
+ JB[oBR + pR] -= dNdPhiR;
1333
+ }
1334
+ }
1335
+ }
1336
+ }
1337
+
1338
+ // A face: its flux node's rows are the interface laws (φ law, species links, reactions), and
1339
+ // its fluxes enter the two edge nodes' balances.
1340
+ _face(f) {
1341
+ const { model, n, M, u, uLo, res, z, VT } = this, R = this.rix, F = FARADAY;
1342
+ const grid = model.grid, interfaces = model.interfaces;
1343
+ const bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
1344
+ const itf = interfaces[f];
1345
+ // φ law: pinned jump (dipole), Helmholtz capacitor, or no charge at all (neutral).
1346
+ const law = itf.phi.type;
1347
+ const condSide = itf.conductor && law !== 'neutral' ? itf.conductor.side : null;
1348
+ if (condSide) {
1349
+ // Against a metal: the other side's φ is tied to the metal's Fermi level V_F = V_T η/z.
1350
+ const im = itf.conductor.i, bm = condSide === 'left' ? bL : bR, bo = condSide === 'left' ? bR : bL;
1351
+ const sg = condSide === 'left' ? 1 : -1;
1352
+ const vf = (u[bm * M + 1 + im] + uLo[bm * M + 1 + im]) / z[im]; // V_F / V_T
1353
+ const gap = vf - itf.zeroCharge / VT - (u[bo * M] + uLo[bo * M]); // (V_F − zeroCharge − φ_edge)/V_T
1354
+ if (law === 'pinned') {
1355
+ res[R[bf * M]] = gap;
1356
+ this._j(bf, 0, bm, 1 + im, 1 / z[im]);
1357
+ this._j(bf, 0, bo, 0, -1);
1358
+ } else {
1359
+ // D toward the other side = C (V_F − zeroCharge − φ_edge); toward +x that's sg times it.
1360
+ const kC = itf.phi.C * VT;
1361
+ res[R[bf * M]] = u[bf * M] - sg * kC * gap;
1362
+ this._j(bf, 0, bf, 0, 1);
1363
+ this._j(bf, 0, bm, 1 + im, (-sg * kC) / z[im]);
1364
+ this._j(bf, 0, bo, 0, sg * kC);
1365
+ }
1366
+ } else if (law === 'neutral') {
1367
+ // D = 0 (not an unknown); the jump is whatever each side's neutrality needs
1368
+ } else {
1369
+ const jump = u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]) - itf.dipole / VT;
1370
+ if (law === 'pinned') {
1371
+ res[R[bf * M]] = jump;
1372
+ this._j(bf, 0, bR, 0, 1);
1373
+ this._j(bf, 0, bL, 0, -1);
1374
+ } else {
1375
+ // D = −C (φ_R − φ_L − dipole): displacement toward +x drops across the layer.
1376
+ const kC = itf.phi.C * VT;
1377
+ res[R[bf * M]] = u[bf * M] + kC * jump;
1378
+ this._j(bf, 0, bf, 0, 1);
1379
+ this._j(bf, 0, bR, 0, kC);
1380
+ this._j(bf, 0, bL, 0, -kC);
1381
+ }
1382
+ }
1383
+ // D_f enters each side's Gauss row; a metal side holds it as surface carriers instead.
1384
+ const gL = grid.regionEnd[f], gR = gL + 1;
1385
+ if (this.nodeConductor[gL] < 0) {
1386
+ res[R[bL * M]] += u[bf * M];
1387
+ this._j(bL, 0, bf, 0, 1);
1388
+ }
1389
+ if (this.nodeConductor[gR] < 0) {
1390
+ res[R[bR * M]] -= u[bf * M] + itf.sheetCharge;
1391
+ this._j(bR, 0, bf, 0, -1);
1392
+ }
1393
+ for (let i = 0; i < n; i++) {
1394
+ const r = 1 + i, o = bf * M + r;
1395
+ const type = itf.links[i].type;
1396
+ const deta = u[bL * M + r] - u[bR * M + r] + (uLo[bL * M + r] - uLo[bR * M + r]); // η_L − η_R
1397
+ if (type === 'blocked') continue;
1398
+ if (type === 'equilibrium') {
1399
+ res[R[o]] = -deta; // μ̄ continuous
1400
+ this._j(bf, r, bR, r, 1);
1401
+ this._j(bf, r, bL, r, -1);
1402
+ } else {
1403
+ // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
1404
+ const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
1405
+ res[R[o]] = u[o] - gG * deta;
1406
+ this._j(bf, r, bf, r, 1);
1407
+ this._j(bf, r, bL, r, -gG);
1408
+ this._j(bf, r, bR, r, gG);
1409
+ }
1410
+ res[R[bL * M + r]] += u[o];
1411
+ this._j(bL, r, bf, r, 1);
1412
+ res[R[bR * M + r]] -= u[o];
1413
+ this._j(bR, r, bf, r, -1);
1414
+ }
1415
+ itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR));
1416
+ }
1417
+
1418
+ // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
1419
+ // extra potential, linear along the segment like φ, so Δ = zΔφ̂ + Δex and
1420
+ // N = −(D/h)·B(Δ)·c_L·expm1(η_R − η_L),
1421
+ // still exactly zero at equilibrium. c_L and ex depend on every ζ at their node through K.
1422
+ _segmentConcentrated(s, bL, bR, mat, h, vel) {
1423
+ const R = this.rix;
1424
+ const { n, M, u, uLo, res, c, z, K, ex, jL, jR } = this;
1425
+ const gL = s, gR = s + 1, KL = gL * n * n, KR = gR * n * n;
1426
+ for (let i = 0; i < n; i++) {
1427
+ if (!mat.present[i] || mat.D[i] === 0) continue;
1428
+ const r = 1 + i, zi = z[i];
1429
+ const cL = c[gL * n + i], cR = c[gR * n + i];
1430
+ const g = mat.D[i] / h;
1431
+ const pe = (vel * h) / mat.D[i];
1432
+ const d = zi * (u[bR * M] - u[bL * M]) + ex[gR * n + i] - ex[gL * n + i] - pe;
1433
+ const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
1434
+ const E = Math.expm1(deta);
1435
+ const B = bernoulli(d), Bp = bernoulliDerivative(d);
1436
+ const gBc = g * B * cL;
1437
+ const N = -gBc * E;
1438
+ const dNdd = -g * Bp * cL * E;
1439
+ // Left node: ∂N/∂ζ_Lj = −gE(B + B′)K_L,ij − dNdd·δ_ij, plus the direct η_L,i term.
1440
+ const fL = -g * E * (B + Bp);
1441
+ jL[0] = -fL * this._Kz(gL, i);
1442
+ for (let j = 0; j < n; j++) jL[1 + j] = fL * K[KL + i * n + j];
1443
+ jL[r] += -dNdd + gBc * (E + 1);
1444
+ // Right node: ∂N/∂ζ_Rj = dNdd·(δ_ij − K_R,ij/c_R), plus the direct η_R,i term.
1445
+ jR.fill(0);
1446
+ if (cR > 0) {
1447
+ const fR = -dNdd / cR;
1448
+ jR[0] = -fR * this._Kz(gR, i);
1449
+ for (let j = 0; j < n; j++) jR[1 + j] = fR * K[KR + i * n + j];
1450
+ } else {
1451
+ jR[0] = dNdd * zi; // K/c → δ as c → 0
1452
+ jR[r] -= dNdd;
1453
+ }
1454
+ jR[r] += dNdd - gBc * (E + 1);
1455
+ res[R[bL * M + r]] += N;
1456
+ res[R[bR * M + r]] -= N;
1457
+ for (let k = 0; k < M; k++) {
1458
+ if (jL[k] !== 0) {
1459
+ this._j(bL, r, bL, k, jL[k]);
1460
+ this._j(bR, r, bL, k, -jL[k]);
1461
+ }
1462
+ if (jR[k] !== 0) {
1463
+ this._j(bL, r, bR, k, jR[k]);
1464
+ this._j(bR, r, bR, k, -jR[k]);
1465
+ }
1466
+ }
1467
+ }
1468
+ }
1469
+
1470
+ // Ohmic conduction in a metal, in mixed form. The flux J (slot 0 of the left node) obeys
1471
+ // η_R − η_L + J/g = 0, g = σ RT/(z²F² h) (Ohm's law, J = −g Δη),
1472
+ // and enters the two nodes' carrier balances as outflow and inflow.
1473
+ _segmentConductor(s, bL, bR, mat, h) {
1474
+ const R = this.rix;
1475
+ const { M, u, uLo, res, z } = this;
1476
+ const i = mat.conductor.i, r = 1 + i;
1477
+ const g = (mat.conductor.sigma * this.model.RT) / (z[i] * z[i] * FARADAY * FARADAY * h);
1478
+ const J = u[bL * M];
1479
+ res[R[bL * M]] = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) + J / g;
1480
+ this._j(bL, 0, bR, r, 1);
1481
+ this._j(bL, 0, bL, r, -1);
1482
+ this._j(bL, 0, bL, 0, 1 / g);
1483
+ res[R[bL * M + r]] += J;
1484
+ res[R[bR * M + r]] -= J;
1485
+ this._j(bL, r, bL, 0, 1);
1486
+ this._j(bR, r, bL, 0, -1);
1487
+ }
1488
+
1489
+ // Eddy mixing: N = −(D_mix/RT) P ∇μ̄ with P = C − (Cz)(Cz)ᵀ/(zᵀCz), C = diag(c). It mixes
1490
+ // composition without carrying current (zᵀP = 0) and vanishes exactly at equilibrium. On a
1491
+ // segment, N_i = −(D_mix/h) Σ_j P̄_ij Δη_j, with P̄ from the logarithmic mean of each c, so a
1492
+ // neutral species gets exactly −D_mix Δc/h. Only mobile species (D > 0) take part.
1493
+ _segmentMixing(s, bL, bR, Dm, h, mat) {
1494
+ const R = this.rix;
1495
+ const { n, M, u, uLo, res, c, z } = this;
1496
+ const gL = s, gR = s + 1, k0 = Dm / h;
1497
+ const on = mat.mobile ?? (mat.mobile = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0));
1498
+ if (on.length === 0) return;
1499
+ const w = this.mixWork ?? (this.mixWork = [0, 1, 2, 3].map(() => new Float64Array(n)));
1500
+ const [cb, dLa, dLb, de] = w;
1501
+ let S = 0, Q = 0;
1502
+ for (const k of on) {
1503
+ const a = c[gL * n + k], b = c[gR * n + k];
1504
+ // l = ln(c_R/c_L), from the compensated potentials where statistics are ideal
1505
+ const l = this.nodeIdeal[gL]
1506
+ ? 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])
1507
+ : Math.log(b / a);
1508
+ // L = a·f(l), f = expm1(l)/l; ∂L/∂a = f − f′, ∂L/∂b = a f′/b
1509
+ let f, fp;
1510
+ if (Math.abs(l) < 1e-3) {
1511
+ f = 1 + l / 2 + (l * l) / 6 + (l * l * l) / 24;
1512
+ fp = 0.5 + l / 3 + (l * l) / 8 + (l * l * l) / 30;
1513
+ } else {
1514
+ const em = Math.expm1(l);
1515
+ f = em / l;
1516
+ fp = (l * (em + 1) - em) / (l * l);
1517
+ }
1518
+ cb[k] = a * f;
1519
+ dLa[k] = f - fp;
1520
+ dLb[k] = b > 0 ? (a * fp) / b : 0.5;
1521
+ de[k] = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]);
1522
+ S += z[k] * z[k] * cb[k];
1523
+ Q += z[k] * cb[k] * de[k];
1524
+ }
1525
+ const q = S > 0 ? Q / S : 0;
1526
+ const { jL, jR } = this;
1527
+ const dcL = this.dA, dcR = this.dB; // ∂c_k/∂slot at each end
1528
+ for (const i of on) {
1529
+ const r = 1 + i;
1530
+ const N = -k0 * cb[i] * (de[i] - z[i] * q);
1531
+ jL.fill(0);
1532
+ jR.fill(0);
1533
+ for (const j of on) {
1534
+ // direct dependence on Δη_j
1535
+ const P = cb[i] * ((i === j ? 1 : 0) - (S > 0 ? (z[i] * z[j] * cb[j]) / S : 0));
1536
+ jL[1 + j] += k0 * P;
1537
+ jR[1 + j] -= k0 * P;
1538
+ }
1539
+ // through c̄_k: ∂a_i/∂c̄_k = δ_ik (Δη_i − z_i q) − c̄_i z_i (z_k Δη_k − q z_k²)/S
1540
+ for (const k of on) {
1541
+ 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);
1542
+ if (da === 0) continue;
1543
+ da *= -k0;
1544
+ this._dc(gL, k, dcL);
1545
+ this._dc(gR, k, dcR);
1546
+ for (let t = 0; t < M; t++) {
1547
+ jL[t] += da * dLa[k] * dcL[t];
1548
+ jR[t] += da * dLb[k] * dcR[t];
1549
+ }
1550
+ }
1551
+ res[R[bL * M + r]] += N;
1552
+ res[R[bR * M + r]] -= N;
1553
+ for (let t = 0; t < M; t++) {
1554
+ if (jL[t] !== 0) {
1555
+ this._j(bL, r, bL, t, jL[t]);
1556
+ this._j(bR, r, bL, t, -jL[t]);
1557
+ }
1558
+ if (jR[t] !== 0) {
1559
+ this._j(bL, r, bR, t, jR[t]);
1560
+ this._j(bR, r, bR, t, -jR[t]);
1561
+ }
1562
+ }
1563
+ }
1564
+ }
1565
+
1566
+ // ∂c_k/∂slot at node g into d (slot 0 = φ̂, 1 + j = η_j).
1567
+ _dc(g, k, d) {
1568
+ const { n } = this;
1569
+ d.fill(0);
1570
+ if (this.nodeIdeal[g]) {
1571
+ const ck = this.c[g * n + k];
1572
+ d[1 + k] = ck;
1573
+ d[0] = -this.z[k] * ck;
1574
+ return;
1575
+ }
1576
+ const o = g * n * n + k * n;
1577
+ for (let j = 0; j < n; j++) d[1 + j] = this.K[o + j];
1578
+ d[0] = -this._Kz(g, k);
1579
+ }
1580
+
1581
+ // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
1582
+ // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
1583
+ // there when ν > 0). The rate couples the two edge nodes only through the face block between
1584
+ // them, which keeps the system block-tridiagonal.
1585
+ _faceReaction(rx, k, f, bf, bL, bR) {
1586
+ const { n, M, u, uLo, c, res } = this;
1587
+ const R = this.rix;
1588
+ const grid = this.model.grid;
1589
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
1590
+ const al = rx.alpha;
1591
+ const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
1592
+ let pref = rx.k0, aHi = rx.fixedA, aLo = 0;
1593
+ for (const p of rx.part) {
1594
+ const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
1595
+ aHi -= p.nu * u[o];
1596
+ aLo -= p.nu * uLo[o];
1597
+ if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1598
+ const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1599
+ pref *= (c[g * n + p.i] / this.cRef[g * n + p.i]) ** e;
1600
+ this._dlnc(g, p.i, e, p.side ? dR : dL);
1601
+ }
1602
+ const { g: bv, gp } = bvFactor(aHi + aLo, al);
1603
+ const rate = pref * bv;
1604
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
1605
+ for (let s = 0; s < M; s++) {
1606
+ dL[s] *= rate;
1607
+ dR[s] *= rate;
1608
+ }
1609
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
1610
+ const slot = 1 + n + k, o = bf * M + slot;
1611
+ res[R[o]] = u[o] - rate;
1612
+ this._j(bf, slot, bf, slot, 1);
1613
+ for (let s = 0; s < M; s++) {
1614
+ if (dL[s] !== 0) this._j(bf, slot, bL, s, -dL[s]);
1615
+ if (dR[s] !== 0) this._j(bf, slot, bR, s, -dR[s]);
1616
+ }
1617
+ for (const p of rx.part) {
1618
+ const b = p.side ? bR : bL;
1619
+ res[R[b * M + 1 + p.i]] -= p.nu * u[o];
1620
+ this._j(b, 1 + p.i, bf, slot, -p.nu);
1621
+ }
1622
+ }
1623
+
1624
+ // Every terminal's terms: its voltage held or floating, how the residual depends on it
1625
+ // (termB), its current into the device (termI) and how that depends on the state (termC).
1626
+ _terminals(dt) {
1627
+ this._refreshSources();
1628
+ for (let k = 0; k < this.terms.length; k++) {
1629
+ this.termB[k].fill(0);
1630
+ this.termC[k].fill(0);
1631
+ this.termDI[k] = 0;
1632
+ this.termI[k] = 0;
1633
+ }
1634
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k));
1635
+ this._contact('left', dt);
1636
+ this._contact('right', dt);
1637
+ for (const k of this.floating) this._circuit(k);
1638
+ }
1639
+
1640
+ // A floating terminal's circuit law: I − I_set = 0 (driven by a current), or I − (V_src − V)/R = 0
1641
+ // (a source behind a resistance).
1642
+ _circuit(k) {
1643
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1644
+ if (d.kind === 'I') this.termRes[k] = this.termI[k] - src;
1645
+ else {
1646
+ this.termRes[k] = this.termI[k] - (src - this.termV[k]) / d.R;
1647
+ this.termDI[k] += 1 / d.R;
1648
+ }
1649
+ }
1650
+
1651
+ // into += w × (the Jacobian row of block b, slot s), over compact columns.
1652
+ _captureRow(b, s, w, into) {
1653
+ const l = this.loc[b * this.M + s];
1654
+ if (l < 0 || w === 0) return;
1655
+ const { sys, nB } = this, { sizes, offA, offB, offC, offX } = sys, m = sizes[b];
1656
+ for (const [X, nb, off] of [['A', b - 1, offA], ['B', b, offB], ['C', b + 1, offC]]) {
1657
+ if (nb < 0 || nb >= nB) continue;
1658
+ const mc = sizes[nb], o = off[b] + l * mc, arr = sys[X], c0 = offX[nb];
1659
+ for (let c = 0; c < mc; c++) if (arr[o + c] !== 0) into[c0 + c] += w * arr[o + c];
1660
+ }
1661
+ return m;
1662
+ }
1663
+
1664
+ // An internal port (terminal k): its exchange with each node of its window, as a source per
1665
+ // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
1666
+ // row's residual, read just before. Its current into the device is Σ z F × the sources.
1667
+ _port(port, flux, k) {
1668
+ const R = this.rix;
1669
+ const { n, M, u, uLo, res, z, VT } = this;
1670
+ const F = FARADAY, vol = this.model.grid.vol;
1671
+ const B = this.termB[k], C = this.termC[k];
1672
+ flux.fill(0);
1673
+ for (let i = 0; i < n; i++) {
1674
+ const link = port.species[i];
1675
+ if (link.type === 'blocked') continue;
1676
+ const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
1677
+ for (const g of port.nodes) {
1678
+ const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
1679
+ const deta = target - (u[o] + uLo[o]); // (μ̄_out − μ̄)/RT
1680
+ if (link.type === 'equilibrium') {
1681
+ if (g === 0 || g === this.nNodes - 1) continue; // a device end node's level is its contact's business
1682
+ flux[i] += res[R[o]];
1683
+ this._captureRow(b, r, zF, C);
1684
+ this._replaceRow(b, r);
1685
+ this._j(b, r, b, r, 1);
1686
+ res[R[o]] = -deta;
1687
+ B[R[o]] += -z[i] / VT;
1688
+ continue;
1689
+ }
1690
+ // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η)
1691
+ const kk = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
1692
+ res[R[o]] -= v * kk * deta;
1693
+ this._j(b, r, b, r, v * kk);
1694
+ flux[i] += v * kk * deta;
1695
+ if (z[i] !== 0) {
1696
+ B[R[o]] += (-v * kk * z[i]) / VT;
1697
+ C[R[o]] += -zF * v * kk;
1698
+ this.termDI[k] += (zF * v * kk * z[i]) / VT;
1699
+ }
1700
+ }
1701
+ }
1702
+ for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
1703
+ }
1704
+
1705
+
1706
+ // One contact (terminal 0 or 1): record the flux through its outer face and the current into
1707
+ // the device (read from the end box before anything here is added: whatever the box needs
1708
+ // comes through the contact), then add its exchange terms, its equilibrium links and its φ law.
1709
+ _contact(side, dt) {
1710
+ const R = this.rix;
1711
+ const { model, n, M, u, uLo, res, c, z, VT } = this;
1712
+ const F = FARADAY;
1713
+ const ct = model.contacts[side];
1714
+ const k = side === 'left' ? 0 : 1, Vt = this.termV[k];
1715
+ const B = this.termB[k], C = this.termC[k];
1716
+ const g = side === 'left' ? 0 : this.nNodes - 1;
1717
+ const b = this.blockOfNode[g];
1718
+ const sgn = side === 'left' ? 1 : -1;
1719
+ const flux = this.contactFlux[side];
1720
+ const dyn = Number.isFinite(dt);
1721
+
1722
+ // Before anything is added, each balance residual is the flux into the device here.
1723
+ let I = 0;
1724
+ for (let i = 0; i < n; i++) {
1725
+ const active = this.loc[b * M + 1 + i] >= 0;
1726
+ const nIn = active ? res[R[b * M + 1 + i]] : 0;
1727
+ flux[i] = sgn * nIn; // (toward +x)
1728
+ if (active && z[i] !== 0) {
1729
+ I += z[i] * F * nIn;
1730
+ this._captureRow(b, 1 + i, z[i] * F, C);
1731
+ }
1732
+ }
1733
+
1734
+ // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
1735
+ // links (neutral species): N_in = k (μ_out − μ)/RT.
1736
+ for (let i = 0; i < n; i++) {
1737
+ const link = ct.species[i];
1738
+ const o = b * M + 1 + i;
1739
+ if (link.type === 'exchange') {
1740
+ res[R[o]] -= link.k * (link.mu / model.RT - (u[o] + uLo[o]));
1741
+ this._j(b, 1 + i, b, 1 + i, link.k);
1742
+ } else if (link.type === 'conductance') {
1743
+ const Vi = (VT * (u[o] + uLo[o])) / z[i];
1744
+ res[R[o]] -= (link.G * (Vt + link.offset - Vi)) / (z[i] * F);
1745
+ this._j(b, 1 + i, b, 1 + i, (link.G * VT) / (z[i] * z[i] * F));
1746
+ B[R[o]] += -link.G / (z[i] * F);
1747
+ }
1748
+ }
1749
+
1750
+ // Equilibrium links: Dirichlet on the known outside level.
1751
+ for (let i = 0; i < n; i++) {
1752
+ const link = ct.species[i];
1753
+ if (link.type !== 'equilibrium') continue;
1754
+ const o = b * M + 1 + i;
1755
+ this._replaceRow(b, 1 + i);
1756
+ this._j(b, 1 + i, b, 1 + i, 1);
1757
+ res[R[o]] = u[o] + uLo[o] - this.contactEta(side, i);
1758
+ if (z[i] !== 0) B[R[o]] += -z[i] / VT;
1759
+ }
1760
+
1761
+ // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
1762
+ // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
1763
+ const link = ct.phi, Dstart = sgn * this.contactDStart[side];
1764
+ if (this.phiUndefined[g]) {
1765
+ this.contactD[side] = 0;
1766
+ } else if (link.type === 'capacitive') {
1767
+ // Gate, or metal across a Stern layer, at φ_g = V_t − zeroCharge. D toward +x.
1768
+ const Din = link.C * (Vt - link.zeroCharge - VT * u[b * M]);
1769
+ res[R[b * M]] -= Din;
1770
+ this._j(b, 0, b, 0, link.C * VT);
1771
+ B[R[b * M]] += -link.C;
1772
+ this.contactD[side] = sgn * Din;
1773
+ if (dyn) {
1774
+ I += (Din - Dstart) / dt;
1775
+ C[R[b * M]] += (-link.C * VT) / dt;
1776
+ this.termDI[k] += link.C / dt;
1777
+ }
1778
+ } else if (link.type === 'pinned' || link.type === 'bulk') {
1779
+ // The Poisson residual is the outside's charge, D_in.
1780
+ const Din = res[R[b * M]];
1781
+ this.contactD[side] = sgn * Din;
1782
+ if (dyn) {
1783
+ I += (Din - Dstart) / dt;
1784
+ this._captureRow(b, 0, 1 / dt, C);
1785
+ }
1786
+ this._replaceRow(b, 0);
1787
+ if (link.type === 'pinned') {
1788
+ // φ_edge = V_t − zeroCharge (the C → ∞ limit).
1789
+ this._j(b, 0, b, 0, 1);
1790
+ res[R[b * M]] = u[b * M] + uLo[b * M] - (Vt - link.zeroCharge) / VT;
1791
+ B[R[b * M]] += -1 / VT;
1792
+ } else {
1793
+ // Plain bulk: local neutrality.
1794
+ let q = this.rhoFixed[g], dq = 0;
1795
+ for (let i = 0; i < n; i++) {
1796
+ const kk = g * n + i;
1797
+ if (!this.present[kk]) continue;
1798
+ const Kz = this._Kz(g, i);
1799
+ q += F * z[i] * c[kk];
1800
+ dq -= F * z[i] * Kz;
1801
+ if (Kz !== 0) this._j(b, 0, b, 1 + i, F * Kz);
1802
+ }
1803
+ this._j(b, 0, b, 0, dq);
1804
+ res[R[b * M]] = q;
1805
+ }
1806
+ } else {
1807
+ this.contactD[side] = 0;
1808
+ }
1809
+ this.termI[k] = I;
1810
+ }
1811
+
1812
+
1813
+
1814
+
1815
+ // Zero one row of the Jacobian (all three blocks), ready to be replaced.
1816
+ _replaceRow(b, rs) {
1817
+ const l = this.loc[b * this.M + rs];
1818
+ if (l < 0) return;
1819
+ const { sys, nB } = this, sz = sys.sizes, m = sz[b];
1820
+ sys.B.fill(0, sys.offB[b] + l * m, sys.offB[b] + (l + 1) * m);
1821
+ if (b > 0) sys.A.fill(0, sys.offA[b] + l * sz[b - 1], sys.offA[b] + (l + 1) * sz[b - 1]);
1822
+ if (b < nB - 1) sys.C.fill(0, sys.offC[b] + l * sz[b + 1], sys.offC[b] + (l + 1) * sz[b + 1]);
1823
+ }
1824
+
1825
+ // Scale every row by its largest Jacobian entry (in place, residual too).
1826
+ _equilibrate() {
1827
+ const { nB, sys, res, termB } = this;
1828
+ const { A, B, C, sizes, offA, offB, offC, offX } = sys;
1829
+ for (let b = 0; b < nB; b++) {
1830
+ const m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < nB - 1 ? sizes[b + 1] : 0;
1831
+ for (let r = 0; r < m; r++) {
1832
+ const oa = offA[b] + r * mp, ob = offB[b] + r * m, oc = offC[b] + r * mn;
1833
+ let mx = 0;
1834
+ for (let k = 0; k < mp; k++) mx = Math.max(mx, Math.abs(A[oa + k]));
1835
+ for (let k = 0; k < m; k++) mx = Math.max(mx, Math.abs(B[ob + k]));
1836
+ for (let k = 0; k < mn; k++) mx = Math.max(mx, Math.abs(C[oc + k]));
1837
+ if (mx === 0) continue;
1838
+ const s = 1 / mx;
1839
+ for (let k = 0; k < mp; k++) A[oa + k] *= s;
1840
+ for (let k = 0; k < m; k++) B[ob + k] *= s;
1841
+ for (let k = 0; k < mn; k++) C[oc + k] *= s;
1842
+ res[offX[b] + r] *= s;
1843
+ for (let k = 0; k < termB.length; k++) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
1844
+ }
1845
+ }
1846
+ }
1847
+
1848
+ // Largest update among the potential-like unknowns (φ̂ and η at grid nodes).
1849
+ _maxPotentialStep(delta, deltaV = this.deltaV) {
1850
+ const { n, M } = this;
1851
+ const R = this.rix; // (the sink entry of delta is 0)
1852
+ let mx = 0;
1853
+ if (deltaV) for (let a = 0; a < this.floating.length; a++) mx = Math.max(mx, Math.abs(deltaV[a]) / this.VT);
1854
+ for (let g = 0; g < this.nNodes; g++) {
1855
+ const b = this.blockOfNode[g];
1856
+ if (this.nodeConductor[g] < 0) mx = Math.max(mx, Math.abs(delta[R[b * M]])); // (a metal's slot 0 is a flux)
1857
+ for (let i = 0; i < n; i++) {
1858
+ if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + i]]));
1859
+ }
1860
+ }
1861
+ return mx;
1862
+ }
1863
+
1864
+ /**
1865
+ * Newton iteration for one backward-Euler step from the current cOld.
1866
+ * @returns {{converged: boolean, iterations: number, history: number[]}}
1867
+ */
1868
+ newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10 } = {}) {
1869
+ const { delta, res } = this;
1870
+ const deltaV = this.deltaV ?? (this.deltaV = new Float64Array(this.floating.length));
1871
+ const history = [];
1872
+ // A steady solve that fails: record how nearly singular the system was, and where.
1873
+ const fail = (r) => {
1874
+ if (dt === Infinity) this._noteConditioning();
1875
+ return r;
1876
+ };
1877
+ for (let it = 1; it <= maxIter; it++) {
1878
+ try {
1879
+ this.assemble(dt);
1880
+ } catch (err) {
1881
+ // A statistics model can fail far from the solution (e.g. Debye–Hückel beyond its range).
1882
+ if (!(err instanceof SolverError)) throw err;
1883
+ return fail({ converged: false, iterations: it, history, error: err.message });
1884
+ }
1885
+ this._equilibrate();
1886
+ let rmax = 0;
1887
+ for (let k = 0; k < this.SINK; k++) rmax = Math.max(rmax, Math.abs(res[k]));
1888
+ try {
1889
+ this._factor();
1890
+ } catch (err) {
1891
+ return fail({ converged: false, iterations: it, history, error: err.message });
1892
+ }
1893
+ try {
1894
+ const pins = this.constrained && dt === Infinity ? this.constraints : [];
1895
+ deltaV.fill(0);
1896
+ this._solveBordered(res, delta, deltaV, pins);
1897
+ if (this.transformed) this._untransform(delta);
1898
+ } catch (err) {
1899
+ if (!(err instanceof SolverError)) throw err;
1900
+ return fail({ converged: false, iterations: it, history, error: err.message });
1901
+ }
1902
+ const step = this._maxPotentialStep(delta, deltaV);
1903
+ history.push(step);
1904
+ if (!Number.isFinite(step)) return fail({ converged: false, iterations: it, history, error: 'non-finite update' });
1905
+ // Give up early on clear divergence; the caller will take a smaller step instead.
1906
+ if (step > 1e4 || (it > 6 && step > 10 * history[0])) {
1907
+ this.computeConcentrations();
1908
+ return fail({ converged: false, iterations: it, history, error: 'diverging' });
1909
+ }
1910
+ const alpha = step > maxStep ? maxStep / step : 1;
1911
+ this._addToState(delta, -alpha);
1912
+ this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
1913
+ if (alpha === 1 && step < tol) {
1914
+ this.computeConcentrations();
1915
+ return { converged: true, iterations: it, history, residual: rmax };
1916
+ }
1917
+ // Converged as far as round-off allows: the updates are already tiny (below 1e-6 thermal
1918
+ // units, ~26 nV) and have stopped shrinking. A badly conditioned system's floor can sit
1919
+ // above tol: a strictly neutral material on a short step, where φ is fixed only through
1920
+ // fluxes that the storage term dwarfs, rattles at ~1e-9 after converging quadratically.
1921
+ // (Raising this floor lets a weakly held population drift: a MOS capacitor without a
1922
+ // channel port then showed a DC leak through its oxide.)
1923
+ const [p1, p2] = [history[history.length - 2], history[history.length - 3]], floor = 1e-6;
1924
+ if (alpha === 1 && it >= 4 && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
1925
+ this.computeConcentrations();
1926
+ return { converged: true, iterations: it, history, residual: rmax, roundoff: true };
1927
+ }
1928
+ }
1929
+ this.computeConcentrations();
1930
+ return fail({ converged: false, iterations: maxIter, history });
1931
+ }
1932
+
1933
+ // How many digits the last factorisation lost to cancellation, and where (the worst seen since
1934
+ // the steady solve began). Only for diagnosing failures: a saturated species, for instance,
1935
+ // can lose digits harmlessly.
1936
+ _noteConditioning() {
1937
+ let c;
1938
+ try {
1939
+ c = this.sys.cancellation();
1940
+ } catch {
1941
+ return;
1942
+ }
1943
+ if (!(c.digits > (this.conditioning?.digits ?? -1))) return;
1944
+ const { grid } = this.model;
1945
+ let x = NaN, where = '';
1946
+ for (let g = 0; g < this.nNodes; g++) {
1947
+ if (this.blockOfNode[g] !== c.block) continue;
1948
+ x = grid.x[g];
1949
+ where = this.model.regions[grid.nodeRegion[g]].name;
1950
+ }
1951
+ for (let f = 0; f < this.nFaces; f++) {
1952
+ if (this.blockOfFace[f] !== c.block) continue;
1953
+ x = grid.x[grid.regionEnd[f]];
1954
+ where = `interfaces[${f}]`;
1955
+ }
1956
+ this.conditioning = { digits: c.digits, x, where };
1957
+ }
1958
+
1959
+ /**
1960
+ * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
1961
+ * The end time is always t + dt; the result reports the substeps taken.
1962
+ */
1963
+ advance(dt, opts, depth = 0) {
1964
+ const r = this.step(dt, opts);
1965
+ if (r.converged || depth >= 30) return { ...r, substeps: 1 };
1966
+ const a = this.advance(dt / 2, opts, depth + 1);
1967
+ if (!a.converged) return a;
1968
+ const b = this.advance(dt / 2, opts, depth + 1);
1969
+ return {
1970
+ converged: b.converged,
1971
+ iterations: r.iterations + a.iterations + b.iterations,
1972
+ history: b.history,
1973
+ substeps: a.substeps + b.substeps,
1974
+ };
1975
+ }
1976
+
1977
+ // u += scale·d in compensated arithmetic (Knuth two-sum, then renormalise hi/lo).
1978
+ _addToState(d, scale) {
1979
+ const { u, uLo, fullOf } = this;
1980
+ for (let j = 0; j < fullOf.length; j++) {
1981
+ const k = fullOf[j], a = u[k], b = scale * d[j];
1982
+ const s = a + b, bb = s - a;
1983
+ const err = a - (s - bb) + (b - bb);
1984
+ const lo = uLo[k] + err;
1985
+ const hi = s + lo;
1986
+ u[k] = hi;
1987
+ uLo[k] = lo - (hi - s);
1988
+ }
1989
+ }
1990
+
1991
+ /**
1992
+ * One time step of size dt. Backward Euler by default; with `method: 'bdf2'`, variable-step
1993
+ * BDF2 once a previous step exists (and the step ratio is at most 2, for stability). On
1994
+ * failure the state is restored.
1995
+ *
1996
+ * BDF2's storage term (a0 c − (1+ω) c_n + ω²/(1+ω) c_{n−1})/dt is written in backward-Euler
1997
+ * form (c − c*)/(dt/a0), so assembly is shared: c* and the displacement histories replace
1998
+ * the start-of-step values, and dt/a0 replaces dt.
1999
+ */
2000
+ step(dt, opts = {}) {
2001
+ const prev = this.history[0];
2002
+ const w = prev ? dt / prev.dt : 0;
2003
+ const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
2004
+ this.uPrev.set(this.u);
2005
+ this.uPrevLo.set(this.uLo);
2006
+ this.termVPrev.set(this.termV);
2007
+ this.computeConcentrations();
2008
+ const cN = Float64Array.from(this.c);
2009
+ // Contact displacement before the step: as it was at the end of the previous step (under
2010
+ // the parameters then), so a gate-voltage change shows up as displacement current.
2011
+ if (!this.contactDEnd) {
2012
+ this._assembleBookkeeping(dt);
2013
+ this.contactDEnd = { ...this.contactD };
2014
+ }
2015
+ const DN = { ...this.contactDEnd };
2016
+ if (opts.guess) {
2017
+ // Start Newton from a predicted state (e.g. extrapolated from the history).
2018
+ this.u.set(opts.guess);
2019
+ this.uLo.fill(0);
2020
+ }
2021
+ let dtEff = dt;
2022
+ if (bdf) {
2023
+ const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
2024
+ const { cOld } = this;
2025
+ for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
2026
+ this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
2027
+ dtEff = dt / a0;
2028
+ } else {
2029
+ this.cOld.set(cN);
2030
+ this.contactDStart = DN;
2031
+ }
2032
+ // Implicit: sources at the step's end, as seen from within the step (before any jump there).
2033
+ // A step landing on a breakpoint ends on it exactly.
2034
+ const tEnd = this.landing !== undefined && Math.abs(this.time + dt - this.landing) <= 1e-9 * dt ? this.landing : this.time + dt;
2035
+ this.sourceTime = this.steady ? this.time : tEnd;
2036
+ this.sourceBefore = !this.steady;
2037
+ this.combining = true;
2038
+ let result;
2039
+ try {
2040
+ result = this.newton(dtEff, opts);
2041
+ } finally {
2042
+ this.combining = false;
2043
+ }
2044
+ result.bdf = bdf;
2045
+ if (result.converged) {
2046
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, D: DN });
2047
+ if (this.history.length > 3) this.history.length = 3;
2048
+ this.time = tEnd;
2049
+ this.lastDt = dtEff;
2050
+ this.contactDOld = this.contactDStart;
2051
+ this._accumulateBoundaryIntake(dtEff, cN);
2052
+ } else {
2053
+ this.u.set(this.uPrev);
2054
+ this.uLo.set(this.uPrevLo);
2055
+ this.termV.set(this.termVPrev);
2056
+ this.sourceTime = this.time;
2057
+ this.computeConcentrations();
2058
+ }
2059
+ this.sourceBefore = false;
2060
+ return result;
2061
+ }
2062
+
2063
+ /**
2064
+ * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
2065
+ * estimated against an explicit predictor through the previous states, and controlled to
2066
+ * `tol` thermal units per step in every potential (φ̂, each η, a floating terminal). The first
2067
+ * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
2068
+ * animation can call it once per frame; the step size carries over between calls.
2069
+ */
2070
+ integrate(tEnd, opts = {}) {
2071
+ const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
2072
+ const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
2073
+ const start = clock();
2074
+ const trace = { t: [], current: [], voltage: [] };
2075
+ let steps = 0, rejected = 0, iterations = 0, failed = false;
2076
+ // Already there (an animation frame with no time to add): nothing to do, and the step size
2077
+ // carried to the next call stays as it was.
2078
+ if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace: { t: [], current: [], voltage: [] } };
2079
+ let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2080
+ if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2081
+ let grow = 0; // longer first steps tried after a Newton failure (see below)
2082
+ const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
2083
+ const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * (tol / err) ** (1 / (p + 1)))) : 2);
2084
+ while (this.time < tEnd) {
2085
+ if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
2086
+ // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
2087
+ // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
2088
+ // strictly neutral device, fails outright.
2089
+ if (tEnd - this.time <= 1e-10 * Math.abs(tEnd)) {
2090
+ this.time = tEnd;
2091
+ break;
2092
+ }
2093
+ const remaining = tEnd - this.time;
2094
+ let h = Math.min(dt, dtMax);
2095
+ let clamped = h >= remaining * (1 - 1e-9);
2096
+ if (clamped) h = remaining;
2097
+ // Land on the waveforms' breakpoints, where a source's slope jumps.
2098
+ const tb = this.terms.reduce((m, t) => Math.min(m, nextBreakpoint(t.drive.src, this.time)), Infinity);
2099
+ const atBreak = tb - this.time <= h * (1 + 1e-9);
2100
+ if (atBreak) {
2101
+ h = tb - this.time;
2102
+ clamped = true;
2103
+ }
2104
+ this.landing = atBreak ? tb : clamped ? tEnd : undefined;
2105
+ const snap = this._snapshot();
2106
+ let err, p, half = null;
2107
+ if (this.history.length === 0) {
2108
+ // First step: backward Euler, checked against two half steps (whose result is kept).
2109
+ const full = this.step(h);
2110
+ iterations += full.iterations;
2111
+ if (full.converged) {
2112
+ const uFull = Float64Array.from(this.u);
2113
+ this._restore(snap);
2114
+ const a = this.step(h / 2);
2115
+ // The first half step is kept too, so it goes in the trace (a jump's charge is in it).
2116
+ half = a.converged ? { t: this.time, current: this._terminalCurrent(), voltage: this.termV[1] - this.termV[0] } : null;
2117
+ const b = a.converged ? this.step(h / 2) : a;
2118
+ iterations += a.iterations + (b === a ? 0 : b.iterations);
2119
+ if (b.converged) {
2120
+ err = this._errorNorm(uFull);
2121
+ p = 1;
2122
+ }
2123
+ }
2124
+ if (err === undefined) {
2125
+ this._restore(snap);
2126
+ rejected++;
2127
+ // Newton failed on the first step after a start or a jump. Usually a shorter step
2128
+ // helps; but right after a jump in a strictly neutral (ε = 0) material, the shorter the
2129
+ // step the worse conditioned it is (the field is fixed only through fluxes, which the
2130
+ // storage term dwarfs), so try longer ones first.
2131
+ if (this.strictlyNeutral && grow < 3 && h * 16 < tEnd - this.time) {
2132
+ grow++;
2133
+ dt = h * 16;
2134
+ } else {
2135
+ grow = 3;
2136
+ dt = h / 4;
2137
+ }
2138
+ if (dt < 1e-14 * Math.max(tEnd, 1e-300)) { failed = true; break; }
2139
+ continue;
2140
+ }
2141
+ } else {
2142
+ const r = this.step(h, { method, guess: this._extrapolate(guess, this.time + h) });
2143
+ iterations += r.iterations;
2144
+ if (!r.converged) {
2145
+ rejected++;
2146
+ dt = h / 4;
2147
+ if (dt < 1e-14 * Math.max(tEnd, 1e-300)) { failed = true; break; }
2148
+ continue;
2149
+ }
2150
+ p = this._predict(pred, r.bdf);
2151
+ err = this._errorNorm(pred) * p.scale;
2152
+ p = p.order;
2153
+ }
2154
+ if (err > tol) {
2155
+ this._restore(snap);
2156
+ rejected++;
2157
+ dt = h * factor(err, p);
2158
+ continue;
2159
+ }
2160
+ steps++;
2161
+ grow = 0;
2162
+ dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
2163
+ if (atBreak) this.history = []; // the solution's slope jumps here: restart the order
2164
+ if (half) {
2165
+ trace.t.push(half.t);
2166
+ trace.current.push(half.current);
2167
+ trace.voltage.push(half.voltage);
2168
+ }
2169
+ trace.t.push(this.time);
2170
+ trace.current.push(this._terminalCurrent());
2171
+ trace.voltage.push(this.termV[1] - this.termV[0]);
2172
+ }
2173
+ this.dtNext = dt;
2174
+ this.landing = undefined;
2175
+ return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace };
2176
+ }
2177
+
2178
+ // Quadratic (or linear) extrapolation of the state to time t, from the current state and the
2179
+ // two most recent history entries: a starting guess for Newton.
2180
+ _extrapolate(out, t) {
2181
+ const [e0, e1] = this.history;
2182
+ const t0 = this.time, u = this.u;
2183
+ if (!e0) return undefined;
2184
+ if (!e1) {
2185
+ const l = (t - t0) / (t0 - e0.t);
2186
+ for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
2187
+ return out;
2188
+ }
2189
+ const t1 = e0.t, t2 = e1.t;
2190
+ const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
2191
+ const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
2192
+ const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
2193
+ for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
2194
+ return out;
2195
+ }
2196
+
2197
+ // Explicit predictor at the new time through the previous states (quadratic after BDF2,
2198
+ // linear after backward Euler), and the factor turning |u − pred| into the local error.
2199
+ _predict(pred, bdf) {
2200
+ const [e0, e1, e2] = this.history;
2201
+ const t = this.time, h = e0.dt;
2202
+ if (!e1) {
2203
+ pred.set(e0.u);
2204
+ return { scale: 1, order: 1 };
2205
+ }
2206
+ const hp = e1.dt;
2207
+ if (bdf && e2) {
2208
+ const hpp = e2.dt;
2209
+ // Lagrange through (t0, u0), (t1, u1), (t2, u2) at t.
2210
+ const t0 = e0.t, t1 = e1.t, t2 = e2.t;
2211
+ const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
2212
+ const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
2213
+ const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
2214
+ for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
2215
+ const w = h / hp;
2216
+ const Cc = (h ** 3 * (1 + w) ** 2) / (w * (1 + 2 * w));
2217
+ const Cp = h * (h + hp) * (h + hp + hpp);
2218
+ return { scale: Cc / (Cc + Cp), order: 2 };
2219
+ }
2220
+ const l0 = (t - e1.t) / (e0.t - e1.t), l1 = 1 - l0;
2221
+ for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
2222
+ // Backward Euler's error against a linear predictor; after a BDF2 step without enough
2223
+ // history this overestimates, which is safe.
2224
+ return { scale: bdf ? 1 : h / (2 * h + hp), order: 1 };
2225
+ }
2226
+
2227
+ // Largest difference from ref among the potentials that carry state, in thermal units.
2228
+ _errorNorm(ref) {
2229
+ const { n, M, u } = this;
2230
+ let mx = 0;
2231
+ for (let g = 0; g < this.nNodes; g++) {
2232
+ const b = this.blockOfNode[g];
2233
+ if (!this.phiUndefined[g]) mx = Math.max(mx, Math.abs(u[b * M] - ref[b * M]));
2234
+ for (let i = 0; i < n; i++) {
2235
+ if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
2236
+ }
2237
+ }
2238
+ return mx;
2239
+ }
2240
+
2241
+ _snapshot() {
2242
+ return {
2243
+ u: Float64Array.from(this.u),
2244
+ uLo: Float64Array.from(this.uLo),
2245
+ cOld: Float64Array.from(this.cOld),
2246
+ time: this.time,
2247
+ lastDt: this.lastDt,
2248
+ contactDStart: this.contactDStart,
2249
+ contactDOld: this.contactDOld,
2250
+ contactDEnd: this.contactDEnd,
2251
+ boundaryIntake: Float64Array.from(this.boundaryIntake),
2252
+ history: this.history.slice(),
2253
+ termV: Float64Array.from(this.termV),
2254
+ sourceTime: this.sourceTime,
2255
+ };
2256
+ }
2257
+
2258
+ _restore(s) {
2259
+ this.u.set(s.u);
2260
+ this.uLo.set(s.uLo);
2261
+ this.cOld.set(s.cOld);
2262
+ this.time = s.time;
2263
+ this.lastDt = s.lastDt;
2264
+ this.contactDStart = s.contactDStart;
2265
+ this.contactDOld = s.contactDOld;
2266
+ this.contactDEnd = s.contactDEnd;
2267
+ this.boundaryIntake.set(s.boundaryIntake);
2268
+ this.history = s.history.slice();
2269
+ this.termV.set(s.termV);
2270
+ this.sourceTime = s.sourceTime;
2271
+ this.computeConcentrations();
2272
+ }
2273
+
2274
+ // Terminal current toward +x at the right contact, from the last assembly at this state.
2275
+ _terminalCurrent() {
2276
+ let I = 0;
2277
+ for (let i = 0; i < this.n; i++) I += FARADAY * this.z[i] * this.contactFlux.right[i];
2278
+ const dt = this.lastDt;
2279
+ return I + (Number.isFinite(dt) ? (this.contactD.right - this.contactDOld.right) / dt : 0);
2280
+ }
2281
+
2282
+ /**
2283
+ * Small-signal impedance about the current (steady) state, seen at one terminal. Linearising
2284
+ * gives (J + iωM)·δx + Σ B_k δV_k = rhs for the grid's rows, and (C_k + iωC′_k)·δx +
2285
+ * (∂I_k/∂V_k)·δV_k = δI_set,k for each floating terminal's circuit row, with J the steady
2286
+ * Jacobian and M the storage part (from J(dt) = J + M/dt), B_k = ∂res/∂V_k and C_k = ∂I_k/∂x.
2287
+ * At the measured terminal, a held voltage is perturbed (rhs = −B δV) and its current read
2288
+ * from C, or a driven current is perturbed and its voltage solved for. The other terminals
2289
+ * keep their drives: held ones at AC ground, driven ones at AC open circuit.
2290
+ * Z = δV/δI with I into the device (positive real part for a passive device).
2291
+ * @param {ArrayLike<number>} frequencies Hz
2292
+ */
2293
+ impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
2294
+ const { M, nB, sys, VT } = this;
2295
+ const kT = this.terms.findIndex((t) => t.name === terminal);
2296
+ if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
2297
+ const dT = this.terms[kT].drive;
2298
+ if (dT.R > 0) throw new SolverError(`impedance: terminal '${terminal}' has a series resistance; that belongs to the external circuit`);
2299
+ const N = nB * M, fl = this.floating, K = fl.length;
2300
+ this.sourceTime = this.time;
2301
+ this.computeConcentrations();
2302
+ this.cOld.set(this.c);
2303
+ this.assemble(Infinity);
2304
+ this.contactDStart = { ...this.contactD };
2305
+ this.assemble(Infinity);
2306
+ // The steady parts, then the storage parts from an assembly at a tiny dt.
2307
+ const J = { A: sys.A.slice(), B: sys.B.slice(), C: sys.C.slice() };
2308
+ const Bt = this.termB.map((v) => v.slice()), Ct = this.termC.map((v) => v.slice()), DI = Float64Array.from(this.termDI);
2309
+ const dts = 1e-30;
2310
+ this.assemble(dts);
2311
+ const S = {};
2312
+ for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
2313
+ const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
2314
+ const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
2315
+ // J·v + (∂res/∂V_k)σ from the residual itself, by a central difference: the assembled J holds
2316
+ // a flux's dependence on η_L and η_R as two entries, and where both are huge (an inversion
2317
+ // layer) and v nearly uniform, J·v loses the flux to round-off; the residual takes the η
2318
+ // difference first, in double-double. So η moves in the low word, φ̂ and the flux unknowns
2319
+ // in the high one (φ̂ only scales expm1(Δη), near zero there).
2320
+ const u0 = Float64Array.from(this.u), uLo0 = Float64Array.from(this.uLo), V0 = Float64Array.from(this.termV);
2321
+ // The unknowns perturbed in the low word: a node's η (not φ̂, a metal's segment flux, or a
2322
+ // face's unknowns, which are read from the high word).
2323
+ const low = new Uint8Array(this.fullOf.length);
2324
+ const nodeBlock = new Uint8Array(nB);
2325
+ for (let g = 0; g < this.nNodes; g++) if (this.nodeConductor[g] < 0) nodeBlock[this.blockOfNode[g]] = 1;
2326
+ for (let j = 0; j < low.length; j++) {
2327
+ const full = this.fullOf[j];
2328
+ low[j] = nodeBlock[Math.floor(full / M)] && full % M !== 0 ? 1 : 0;
2329
+ }
2330
+ const resP = new Float64Array(this.res.length), resM = new Float64Array(this.res.length);
2331
+ const derivative = (v, k, sigma, out) => {
2332
+ let mx = Math.abs(sigma);
2333
+ for (let j = 0; j < v.length; j++) mx = Math.max(mx, Math.abs(v[j]));
2334
+ const h = 1e-4 / (mx || 1);
2335
+ for (const [sgn, into] of [[1, resP], [-1, resM]]) {
2336
+ this.u.set(u0);
2337
+ this.uLo.set(uLo0);
2338
+ this.termV.set(V0);
2339
+ for (let j = 0; j < v.length; j++) {
2340
+ const full = this.fullOf[j];
2341
+ if (low[j]) this.uLo[full] += sgn * h * v[j];
2342
+ else this.u[full] += sgn * h * v[j];
2343
+ }
2344
+ // A held terminal's voltage comes from its source at each assembly; a floating one's is
2345
+ // an unknown.
2346
+ if (k >= 0 && fl.includes(k)) this.termV[k] += sgn * h * sigma;
2347
+ else if (k >= 0) this.sourceOverride.set(k, V0[k] + sgn * h * sigma);
2348
+ this.assemble(Infinity);
2349
+ if (k >= 0) this.sourceOverride.delete(k);
2350
+ into.set(this.res);
2351
+ }
2352
+ for (let j = 0; j < out.length; j++) out[j] = (resP[j] - resM[j]) / (2 * h);
2353
+ };
2354
+ const restore = () => {
2355
+ this.u.set(u0);
2356
+ this.uLo.set(uLo0);
2357
+ this.termV.set(V0);
2358
+ this._refreshSources();
2359
+ this.computeConcentrations();
2360
+ };
2361
+
2362
+ const { sizes, offA, offB, offC, offX } = sys;
2363
+ const csys = new ComplexBlockTridiagonal(nB, sizes);
2364
+ const Nc = csys.size;
2365
+ const rr = new Float64Array(Nc), ri = new Float64Array(Nc);
2366
+ const yr = new Float64Array(Nc), yi = new Float64Array(Nc);
2367
+ const Xr = fl.map(() => new Float64Array(Nc)), Xi = fl.map(() => new Float64Array(Nc));
2368
+ const xr = new Float64Array(N), xi = new Float64Array(N);
2369
+ const rRes = new Float64Array(Nc), iRes = new Float64Array(Nc);
2370
+ const jr = new Float64Array(Nc), ji = new Float64Array(Nc), sr = new Float64Array(Nc), si = new Float64Array(Nc);
2371
+ const out = { f: Float64Array.from(frequencies), Z: { re: new Float64Array(frequencies.length), im: new Float64Array(frequencies.length) } };
2372
+ if (profiles) out.profiles = [];
2373
+ // (C + iωC′)·(ar + i ai) for terminal k
2374
+ const dot = (k, w, ar, ai) => {
2375
+ let re = 0, im = 0;
2376
+ const c = Ct[k], cs = Cs[k];
2377
+ for (let j = 0; j < Nc; j++) {
2378
+ re += c[j] * ar[j] - w * cs[j] * ai[j];
2379
+ im += c[j] * ai[j] + w * cs[j] * ar[j];
2380
+ }
2381
+ return [re, im];
2382
+ };
2383
+ Array.from(frequencies).forEach((f, q) => {
2384
+ const w = 2 * Math.PI * f;
2385
+ if (!(w > 0)) throw new SolverError('impedance: frequencies must be positive');
2386
+ // Rows scaled by their largest entry (and the terminal columns with them).
2387
+ const scale = new Float64Array(Nc);
2388
+ for (let blk = 0; blk < nB; blk++) {
2389
+ const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
2390
+ for (let r = 0; r < m; r++) {
2391
+ const rows = [['A', offA[blk] + r * mp, mp], ['B', offB[blk] + r * m, m], ['C', offC[blk] + r * mn, mn]];
2392
+ let mx = 0;
2393
+ 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]));
2394
+ const sc = mx > 0 ? 1 / mx : 1;
2395
+ scale[offX[blk] + r] = sc;
2396
+ for (const [X, o, len] of rows) {
2397
+ const JX = J[X], SX = S[X], Xre = csys[X + 'r'], Xim = csys[X + 'i'];
2398
+ for (let c = 0; c < len; c++) {
2399
+ Xre[o + c] = JX[o + c] * sc;
2400
+ Xim[o + c] = w * SX[o + c] * sc;
2401
+ }
2402
+ }
2403
+ }
2404
+ }
2405
+ csys.factor();
2406
+ // Each solve uses that residual-based J·v (S·v is safe from the matrix): in an inversion
2407
+ // layer fed by minority carriers that number ~1e3 cm⁻³ in the bulk, the assembled J alone
2408
+ // gives the layer an exchange path many orders too fast.
2409
+ const sx = (X, vr, out) => {
2410
+ for (let blk = 0; blk < nB; blk++) {
2411
+ const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
2412
+ for (let r = 0; r < m; r++) {
2413
+ let acc = 0;
2414
+ for (let c = 0; c < mp; c++) acc += X.A[offA[blk] + r * mp + c] * vr[offX[blk - 1] + c];
2415
+ for (let c = 0; c < m; c++) acc += X.B[offB[blk] + r * m + c] * vr[offX[blk] + c];
2416
+ for (let c = 0; c < mn; c++) acc += X.C[offC[blk] + r * mn + c] * vr[offX[blk + 1] + c];
2417
+ out[offX[blk] + r] = acc;
2418
+ }
2419
+ }
2420
+ };
2421
+ // A x = −σ ∂res/∂V_k, x = outr + i outi (σ = 1 for the response to the measured terminal,
2422
+ // −1 for a floating one's unit δV): GMRES on the system preconditioned by the factorised one
2423
+ // (whose error is concentrated in a few slow modes, which it finds in a few iterations).
2424
+ const zero = new Float64Array(Nc);
2425
+ const precondition = (ar, ai) => {
2426
+ const outR = new Float64Array(Nc), outI = new Float64Array(Nc);
2427
+ for (let j = 0; j < Nc; j++) {
2428
+ rRes[j] = ar[j] * scale[j];
2429
+ iRes[j] = ai[j] * scale[j];
2430
+ }
2431
+ csys.solve(rRes, iRes, outR, outI);
2432
+ return [outR, outI];
2433
+ };
2434
+ const op = ([vr, vi]) => {
2435
+ derivative(vr, -1, 0, jr);
2436
+ derivative(vi, -1, 0, ji);
2437
+ sx(S, vi, si);
2438
+ sx(S, vr, sr);
2439
+ const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
2440
+ for (let j = 0; j < Nc; j++) {
2441
+ ar[j] = jr[j] - w * si[j];
2442
+ ai[j] = ji[j] + w * sr[j];
2443
+ }
2444
+ return precondition(ar, ai);
2445
+ };
2446
+ const solve = (br, bi, outr, outi, k, sigma) => {
2447
+ const rhs = k >= 0 ? precondition(Bt[k].map((v) => -sigma * v), zero) : [new Float64Array(Nc), new Float64Array(Nc)];
2448
+ outr.set(rhs[0]);
2449
+ outi.set(rhs[1]);
2450
+ gmres(op, rhs, [outr, outi]);
2451
+ restore();
2452
+ };
2453
+ // y: the response with the floating terminals held; X_k: to a unit δV_k.
2454
+ const heldT = dT.kind === 'V';
2455
+ for (let j = 0; j < Nc; j++) {
2456
+ rr[j] = heldT ? -Bt[kT][j] * scale[j] : 0;
2457
+ ri[j] = 0;
2458
+ }
2459
+ solve(rr, ri, yr, yi, heldT ? kT : -1, heldT ? 1 : 0);
2460
+ fl.forEach((k, a) => {
2461
+ for (let j = 0; j < Nc; j++) rr[j] = Bt[k][j] * scale[j];
2462
+ ri.fill(0);
2463
+ solve(rr, ri, Xr[a], Xi[a], k, -1);
2464
+ });
2465
+ // The floating terminals' circuit rows, a small complex system for their δV.
2466
+ const Ar = Array.from({ length: K }, () => new Float64Array(K + 1)), Ai = Array.from({ length: K }, () => new Float64Array(K + 1));
2467
+ fl.forEach((k, a) => {
2468
+ for (let b = 0; b < K; b++) {
2469
+ const [re, im] = dot(k, w, Xr[b], Xi[b]);
2470
+ Ar[a][b] = -re;
2471
+ Ai[a][b] = -im;
2472
+ }
2473
+ Ar[a][a] += DI[k];
2474
+ Ai[a][a] += w * DIs[k];
2475
+ const [yre, yim] = dot(k, w, yr, yi);
2476
+ Ar[a][K] = (k === kT ? 1 : 0) - yre;
2477
+ Ai[a][K] = -yim;
2478
+ });
2479
+ const dV = complexSolve(Ar, Ai, K);
2480
+ // δx = y − Σ X_k δV_k
2481
+ for (let j = 0; j < Nc; j++) {
2482
+ let re = yr[j], im = yi[j];
2483
+ for (let a = 0; a < K; a++) {
2484
+ re -= Xr[a][j] * dV.re[a] - Xi[a][j] * dV.im[a];
2485
+ im -= Xr[a][j] * dV.im[a] + Xi[a][j] * dV.re[a];
2486
+ }
2487
+ yr[j] = re;
2488
+ yi[j] = im;
2489
+ xr[this.fullOf[j]] = re;
2490
+ xi[this.fullOf[j]] = im;
2491
+ }
2492
+ let Zr, Zi;
2493
+ if (heldT) {
2494
+ // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal
2495
+ let [Ir, Ii] = dot(kT, w, yr, yi);
2496
+ Ir += DI[kT];
2497
+ Ii += w * DIs[kT];
2498
+ const d2 = Ir * Ir + Ii * Ii; // Z = 1/δI
2499
+ Zr = Ir / d2;
2500
+ Zi = -Ii / d2;
2501
+ } else {
2502
+ // δV per unit current
2503
+ const a = fl.indexOf(kT);
2504
+ Zr = dV.re[a];
2505
+ Zi = dV.im[a];
2506
+ }
2507
+ out.Z.re[q] = Zr;
2508
+ out.Z.im[q] = Zi;
2509
+ if (profiles) out.profiles.push(this._smallSignalProfiles(xr, xi));
2510
+ });
2511
+ return out;
2512
+ }
2513
+
2514
+ // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
2515
+ _smallSignalProfiles(xr, xi) {
2516
+ const { n, M, z, VT, model } = this;
2517
+ const nN = this.nNodes, RT = model.RT;
2518
+ const pair = () => ({ re: new Float64Array(nN), im: new Float64Array(nN) });
2519
+ const phi = pair(), mu = {}, c = {};
2520
+ for (const sp of model.species) {
2521
+ mu[sp.name] = pair();
2522
+ c[sp.name] = pair();
2523
+ }
2524
+ for (let g = 0; g < nN; g++) {
2525
+ const b = this.blockOfNode[g];
2526
+ const undef = this.phiUndefined[g];
2527
+ phi.re[g] = undef ? NaN : VT * xr[b * M];
2528
+ phi.im[g] = undef ? NaN : VT * xi[b * M];
2529
+ for (let i = 0; i < n; i++) {
2530
+ const name = model.species[i].name;
2531
+ if (!this.present[g * n + i]) {
2532
+ mu[name].re[g] = mu[name].im[g] = c[name].re[g] = c[name].im[g] = NaN;
2533
+ continue;
2534
+ }
2535
+ mu[name].re[g] = RT * xr[b * M + 1 + i];
2536
+ mu[name].im[g] = RT * xi[b * M + 1 + i];
2537
+ // δc_i = Σ_j K_ij (δη_j − z_j δφ̂)
2538
+ let sr = 0, si = 0;
2539
+ for (let j = 0; j < n; j++) {
2540
+ const Kij = this.nodeIdeal[g] ? (i === j ? this.c[g * n + i] : 0) : this.K[g * n * n + i * n + j];
2541
+ if (Kij === 0) continue;
2542
+ sr += Kij * (xr[b * M + 1 + j] - z[j] * xr[b * M]);
2543
+ si += Kij * (xi[b * M + 1 + j] - z[j] * xi[b * M]);
2544
+ }
2545
+ c[name].re[g] = sr;
2546
+ c[name].im[g] = si;
2547
+ }
2548
+ }
2549
+ return { phi, mu, c };
2550
+ }
2551
+
2552
+ // The contact and port readouts (fluxes, displacements, the last segment's current) at the
2553
+ // current state, from only the boxes they're read from. Each of those boxes gets the same terms
2554
+ // in the same order as in assemble(), so the readouts are identical; other residuals and the
2555
+ // Jacobian are left partial (the next assemble() starts afresh). Concentrations must be current.
2556
+ _assembleBookkeeping(dt) {
2557
+ const { model } = this;
2558
+ const { grid, materials, regions } = model;
2559
+ this.res.fill(0);
2560
+ for (let r = 0; r < regions.length; r++) {
2561
+ const { nodes, segs } = this.bookkeeping[r];
2562
+ if (nodes.length === 0 && segs.length === 0) continue;
2563
+ const reg = regions[r], mat = materials[reg.material];
2564
+ if (mat.conductor) {
2565
+ for (const g of nodes) this._nodeConductor(g, dt);
2566
+ for (const s of segs) this._segmentConductor(s, s + r, s + r + 1, mat, grid.segLength[s]);
2567
+ continue;
2568
+ }
2569
+ for (const g of nodes) {
2570
+ if (mat.ideal) this._nodesDilute(g, g, dt);
2571
+ else this._nodeConcentrated(g, dt);
2572
+ }
2573
+ const rxs = this.rxsIn[reg.material];
2574
+ if (rxs.length > 0) for (const g of nodes) this._bulkReactions(g, rxs);
2575
+ for (const s of segs) {
2576
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s];
2577
+ this._segmentDisplacement(s, bL, bR, mat, h);
2578
+ if (reg.mixing > 0) this._segmentMixing(s, bL, bR, reg.mixing, h, mat);
2579
+ if (!mat.ideal) this._segmentConcentrated(s, bL, bR, mat, h, reg.velocity);
2580
+ }
2581
+ if (mat.ideal) for (const s of segs) this._segmentsDilute(s, s + 1, r, mat, reg.velocity);
2582
+ }
2583
+ for (const f of this.bookkeepingFaces) this._face(f);
2584
+ this._terminals(dt);
2585
+ }
2586
+
2587
+ // Add this step's contact fluxes (at the converged state) to each stretch's intake.
2588
+ // (A BDF2 step also moves each amount by Σ v (c* − c_n), its history term.)
2589
+ _accumulateBoundaryIntake(dt, cN) {
2590
+ this._assembleBookkeeping(dt);
2591
+ this.contactDEnd = { ...this.contactD };
2592
+ if (!Number.isFinite(dt)) return;
2593
+ const last = this.model.regions.length - 1;
2594
+ const { n, cOld } = this, vol = this.model.grid.vol;
2595
+ this.stretches.forEach((st, k) => {
2596
+ if (!st.connected) return;
2597
+ let q = 0;
2598
+ if (st.regions[0] === 0) q += this.contactFlux.left[st.species];
2599
+ if (st.regions[1] === last) q -= this.contactFlux.right[st.species];
2600
+ for (const p of st.ports) q += this.portFlux[p][st.species];
2601
+ let hist = 0;
2602
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) hist += vol[g] * (cOld[g * n + st.species] - cN[g * n + st.species]);
2603
+ this.boundaryIntake[k] += q * dt + hist;
2604
+ });
2605
+ }
2606
+
2607
+ // Slowest diffusion time across the device, used to size "giant" steps.
2608
+ slowestTime() {
2609
+ const { model } = this;
2610
+ let dMin = Infinity;
2611
+ for (const mat of model.materials) {
2612
+ for (let i = 0; i < this.n; i++) if (mat.present[i] && mat.D[i] > 0) dMin = Math.min(dMin, mat.D[i]);
2613
+ }
2614
+ const L = model.grid.length;
2615
+ return Number.isFinite(dMin) ? (L * L) / dMin : 1;
2616
+ }
2617
+
2618
+ /**
2619
+ * Steady state.
2620
+ * - If every species stretch is fed by a contact, nothing is conserved on its own, so the
2621
+ * true steady equations (dt = ∞, no storage term) are solved directly: no slow modes to
2622
+ * wait out, however slow the physics (e.g. exponentially scarce minority carriers).
2623
+ * - Otherwise backward-Euler steps at a huge dt, whose storage term pins each conserved
2624
+ * amount exactly; dt keeps growing ×10 while the state still moves.
2625
+ * If Newton fails, dt ramps up from a small value (pseudo-transient continuation) instead.
2626
+ * The clock is not advanced, and open-system conservation bookkeeping restarts here.
2627
+ */
2628
+ solveSteady(opts = {}) {
2629
+ this.conditioning = null;
2630
+ this.sourceTime = this.time;
2631
+ // Continuation applies where the right terminal is held at a voltage, against a held left one.
2632
+ const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
2633
+ const held = (d) => d.kind === 'V' && !(d.R > 0);
2634
+ const target = sourceAt(dr.src, this.time), level = sourceAt(dl.src, this.time);
2635
+ const canContinue = opts.continuation !== false && held(dl) && held(dr) && target !== level;
2636
+ const direct = this._directSteady();
2637
+ const u0 = Float64Array.from(this.u), u0Lo = Float64Array.from(this.uLo), v0 = Float64Array.from(this.termV);
2638
+ // Where a direct solve applies and continuation is possible, don't spend long on the
2639
+ // pseudo-transient ramp: one direct attempt first.
2640
+ const quick = (canContinue || this.hasGeneration) && direct && opts.continuation !== false;
2641
+ let r = this._solveSteady(quick ? { ...opts, maxSteps: 1 } : opts);
2642
+ if (r.converged) this.solvedV = target;
2643
+ if (r.converged) return r;
2644
+ // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
2645
+ // nothing, each solve warm from the last.
2646
+ if (this.hasGeneration && opts.continuation !== false) {
2647
+ this.u.set(u0);
2648
+ this.uLo.set(u0Lo);
2649
+ this.termV.set(v0);
2650
+ this.computeConcentrations();
2651
+ const g = this._generationContinuation(opts, r);
2652
+ if (g.converged) {
2653
+ this.solvedV = target;
2654
+ return g;
2655
+ }
2656
+ r = g;
2657
+ }
2658
+ if (!canContinue) {
2659
+ if (quick) {
2660
+ this.u.set(u0);
2661
+ this.uLo.set(u0Lo);
2662
+ this.termV.set(v0);
2663
+ this.computeConcentrations();
2664
+ const full = this._solveSteady(opts); // the full pseudo-transient ramp
2665
+ return { ...full, steps: full.steps + r.steps, iterations: full.iterations + r.iterations };
2666
+ }
2667
+ return r;
2668
+ }
2669
+ // Source continuation: solve with both terminals level (consistent with a cold start), then
2670
+ // ramp the right terminal's voltage to its target in adaptive steps.
2671
+ const restart = () => {
2672
+ this.u.set(u0);
2673
+ this.uLo.set(u0Lo);
2674
+ this.termV.set(v0);
2675
+ this.computeConcentrations();
2676
+ };
2677
+ restart();
2678
+ // Ramp from the last converged voltage when the state is that solution (a warm start),
2679
+ // otherwise from level terminals.
2680
+ const from = this.solvedV !== undefined ? this.solvedV : level;
2681
+ const c = this._continuation(opts, from, target, r, from !== level);
2682
+ if (c.converged) {
2683
+ this.solvedV = target;
2684
+ return c;
2685
+ }
2686
+ restart();
2687
+ r = this._solveSteady(opts); // the full pseudo-transient ramp
2688
+ if (r.converged) this.solvedV = target;
2689
+ return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
2690
+ }
2691
+
2692
+ // Steady solves with the generation reactions' rates scaled from 1e-12 up to 1, ×100 a step
2693
+ // while they converge, smaller steps where they don't.
2694
+ _generationContinuation(opts, r) {
2695
+ const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
2696
+ let s = 1e-12, factor = 100, steps = r.steps, iterations = r.iterations;
2697
+ const history = r.history.slice();
2698
+ try {
2699
+ this.generationScale = s;
2700
+ let q = this._solveSteady(sub);
2701
+ steps += q.steps;
2702
+ iterations += q.iterations;
2703
+ if (!q.converged) return { converged: false, steps, iterations, history };
2704
+ while (s < 1) {
2705
+ const next = Math.min(1, s * factor);
2706
+ const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
2707
+ this.generationScale = next;
2708
+ q = this._solveSteady(sub);
2709
+ steps += q.steps;
2710
+ iterations += q.iterations;
2711
+ history.push({ generation: next, converged: q.converged });
2712
+ if (q.converged) {
2713
+ s = next;
2714
+ factor = Math.min(100, factor * factor);
2715
+ } else {
2716
+ this.u.set(u1);
2717
+ this.uLo.set(u1Lo);
2718
+ this.termV.set(v1);
2719
+ this.computeConcentrations();
2720
+ factor = Math.sqrt(factor);
2721
+ if (factor < 1.01) return { converged: false, steps, iterations, history };
2722
+ }
2723
+ }
2724
+ return { converged: true, steps, iterations, history };
2725
+ } finally {
2726
+ this.generationScale = 1;
2727
+ }
2728
+ }
2729
+
2730
+ _continuation(opts, level, target, r, warm) {
2731
+ const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
2732
+ let V = level, dV = (target - level) / 8, steps = r.steps, iterations = r.iterations;
2733
+ const history = r.history.slice();
2734
+ try {
2735
+ this.sourceOverride.set(1, V);
2736
+ let q;
2737
+ if (!warm) {
2738
+ q = this._solveSteady(opts);
2739
+ steps += q.steps;
2740
+ iterations += q.iterations;
2741
+ if (!q.converged) return { ...r, steps, iterations };
2742
+ }
2743
+ while (V !== target) {
2744
+ const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
2745
+ const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
2746
+ this.sourceOverride.set(1, next);
2747
+ q = this._solveSteady(sub);
2748
+ steps += q.steps;
2749
+ iterations += q.iterations;
2750
+ history.push({ continuation: next, converged: q.converged });
2751
+ if (q.converged) {
2752
+ V = next;
2753
+ dV *= 1.5;
2754
+ } else {
2755
+ this.u.set(u1);
2756
+ this.uLo.set(u1Lo);
2757
+ this.termV.set(v1);
2758
+ this.computeConcentrations();
2759
+ dV /= 4;
2760
+ if (Math.abs(dV) < 1e-6 * Math.abs(target - level)) return { converged: false, steps, iterations, history };
2761
+ }
2762
+ }
2763
+ return { converged: true, steps, iterations, history };
2764
+ } finally {
2765
+ this.sourceOverride.delete(1);
2766
+ this._refreshSources();
2767
+ }
2768
+ }
2769
+
2770
+ _solveSteady(opts = {}) {
2771
+ this.constrained = true; // spectators' amounts as constraints in the dt = ∞ solves
2772
+ this.steady = true;
2773
+ try {
2774
+ return this._steadySteps(opts);
2775
+ } finally {
2776
+ this.constrained = false;
2777
+ this.steady = false;
2778
+ }
2779
+ }
2780
+
2781
+ _steadySteps({ maxSteps = 80, tol = 1e-11 } = {}) {
2782
+ const time = this.time;
2783
+ const tau = this.slowestTime();
2784
+ const direct = this._directSteady();
2785
+ const giant = direct ? Infinity : 1e6 * tau;
2786
+ if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
2787
+ let dt = giant;
2788
+ let totalIter = 0, steps = 0, converged = false;
2789
+ const history = [];
2790
+ while (steps < maxSteps) {
2791
+ steps++;
2792
+ // Restore conserved amounts exactly before each huge step; the step then re-solves, so
2793
+ // the final state satisfies every equation.
2794
+ if (!direct && dt >= giant) this._renormalizeSpectators();
2795
+ let r = this.step(dt);
2796
+ totalIter += r.iterations;
2797
+ history.push({ dt, converged: r.converged, iterations: r.iterations });
2798
+ if (!r.converged && dt === Infinity && steps === 1) {
2799
+ // A large change can overshoot Newton's usual damping: retry with a tighter limit.
2800
+ r = this.step(dt, { maxStep: 3, maxIter: 80 });
2801
+ totalIter += r.iterations;
2802
+ history.push({ dt, converged: r.converged, iterations: r.iterations, maxStep: 3 });
2803
+ }
2804
+ if (!r.converged) {
2805
+ dt = dt >= giant ? tau * 1e-6 : dt / 4;
2806
+ if (dt < tau * 1e-15) break;
2807
+ continue;
2808
+ }
2809
+ if (dt === Infinity) {
2810
+ converged = true; // the steady equations themselves were solved
2811
+ break;
2812
+ }
2813
+ if (dt >= giant && this._maxPotentialStep(this._diff()) < tol) {
2814
+ converged = true;
2815
+ if (dt > giant) {
2816
+ // Finish at the base giant step, where the storage term pins amounts tightly.
2817
+ this._renormalizeSpectators();
2818
+ const f = this.step(giant);
2819
+ totalIter += f.iterations;
2820
+ converged = f.converged;
2821
+ }
2822
+ break;
2823
+ }
2824
+ if (dt < giant) {
2825
+ dt *= 10; // ramping up after a failure
2826
+ if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
2827
+ } else if (dt < 1e6 * giant) {
2828
+ dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)
2829
+ }
2830
+ }
2831
+ this.time = time;
2832
+ this.history = []; // a steady solve isn't a trajectory: transients restart from here
2833
+ this.dtNext = undefined;
2834
+ if (converged) {
2835
+ this.stretches.forEach((st, k) => {
2836
+ if (!st.connected) return;
2837
+ this.referenceAmounts[k] = this.amount(st);
2838
+ this.boundaryIntake[k] = 0;
2839
+ });
2840
+ }
2841
+ return { converged, steps, iterations: totalIter, history };
2842
+ }
2843
+
2844
+ // Huge steps pin each conserved amount only through a tiny storage term, so round-off can let
2845
+ // it creep. Shift each spectator's level uniformly to restore its amount exactly (in one step
2846
+ // for ideal statistics, where c ∝ e^η; by Newton on the shift otherwise).
2847
+ _renormalizeSpectators() {
2848
+ const { n, M, u } = this;
2849
+ let changed = false;
2850
+ this.stretches.forEach((st, k) => {
2851
+ // A floating conductor holds its charge on its faces, not as an amount: the huge steps keep
2852
+ // it, and there's nothing here to shift.
2853
+ if (!st.spectator || this.model.materials[this.model.regions[st.regions[0]].material].conductor) return;
2854
+ const want = this.referenceAmounts[k];
2855
+ const ideal = this.nodeIdeal.subarray(st.nodes[0], st.nodes[1] + 1).every((v) => v === 1);
2856
+ for (let it = 0; it < (ideal ? 1 : 30); it++) {
2857
+ const now = this.amount(st);
2858
+ if (!(now > 0) || now === want) return;
2859
+ let shift;
2860
+ if (ideal) shift = Math.log(want / now);
2861
+ else {
2862
+ let dA = 0;
2863
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
2864
+ const dc = this.nodeIdeal[g] ? this.c[g * n + st.species] : this.K[g * n * n + st.species * (n + 1)];
2865
+ dA += this.model.grid.vol[g] * dc;
2866
+ }
2867
+ shift = (want - now) / dA;
2868
+ if (!(Math.abs(shift) > 1e-16)) return;
2869
+ }
2870
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) u[this.blockOfNode[g] * M + 1 + st.species] += shift;
2871
+ changed = true;
2872
+ if (!ideal) this.computeConcentrations();
2873
+ }
2874
+ });
2875
+ if (changed) this.computeConcentrations();
2876
+ }
2877
+
2878
+ _diff() {
2879
+ const d = this.delta; // reuse as scratch
2880
+ const { fullOf } = this;
2881
+ for (let j = 0; j < fullOf.length; j++) d[j] = this.u[fullOf[j]] - this.uPrev[fullOf[j]];
2882
+ return d;
2883
+ }
2884
+ }