driftlet 0.9.0 → 0.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solver.js CHANGED
@@ -2,7 +2,65 @@
2
2
  // and its derivative g′(a) = α e^{αa} + (1−α) e^{−(1−α)a}.
3
3
  function bvFactor(a, alpha) {
4
4
  const e = Math.exp(-(1 - alpha) * a);
5
- return { g: e * Math.expm1(a), gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
5
+ // (far from equilibrium, the plain difference: the product would give 0·∞ there)
6
+ const g = Math.abs(a) > 50 ? Math.exp(alpha * a) - e : e * Math.expm1(a);
7
+ return { g, gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
8
+ }
9
+
10
+ // GMRES(m), restarted, for a real system op(x) = b, op already preconditioned; x holds the
11
+ // starting guess and receives the answer. Returns whether it converged.
12
+ function gmresReal(op, b, x, { m = 10, restarts = 3, tol = 1e-12 } = {}) {
13
+ const N = b.length;
14
+ const dot = (a, c) => {
15
+ let t = 0;
16
+ for (let j = 0; j < N; j++) t += a[j] * c[j];
17
+ return t;
18
+ };
19
+ const bn = Math.sqrt(dot(b, b)) || 1;
20
+ for (let cycle = 0; cycle < restarts; cycle++) {
21
+ const ax = op(x), r = Float64Array.from(b, (v, j) => v - ax[j]);
22
+ const beta = Math.sqrt(dot(r, r));
23
+ if (!(beta > tol * bn)) return true;
24
+ const V = [r.map((v) => v / beta)], H = Array.from({ length: m + 1 }, () => new Float64Array(m));
25
+ const cs = [], sn = [], g = new Float64Array(m + 1);
26
+ g[0] = beta;
27
+ let k = 0, converged = false;
28
+ for (; k < m; k++) {
29
+ const w = op(V[k]);
30
+ for (let i = 0; i <= k; i++) {
31
+ const h = dot(V[i], w);
32
+ H[i][k] = h;
33
+ for (let j = 0; j < N; j++) w[j] -= h * V[i][j];
34
+ }
35
+ const hn = Math.sqrt(dot(w, w));
36
+ V.push(w.map((v) => v / (hn || 1)));
37
+ for (let i = 0; i < k; i++) {
38
+ const a = H[i][k], c = H[i + 1][k];
39
+ H[i][k] = cs[i] * a + sn[i] * c;
40
+ H[i + 1][k] = -sn[i] * a + cs[i] * c;
41
+ }
42
+ const t = Math.hypot(H[k][k], hn), c = t > 0 ? H[k][k] / t : 1, sv = t > 0 ? hn / t : 0;
43
+ cs.push(c);
44
+ sn.push(sv);
45
+ H[k][k] = t;
46
+ g[k + 1] = -sv * g[k];
47
+ g[k] *= c;
48
+ if (!(Math.abs(g[k + 1]) > tol * bn) || !(hn > 0)) {
49
+ k++;
50
+ converged = true;
51
+ break;
52
+ }
53
+ }
54
+ const y = new Float64Array(k);
55
+ for (let i = k - 1; i >= 0; i--) {
56
+ let v = g[i];
57
+ for (let j = i + 1; j < k; j++) v -= H[i][j] * y[j];
58
+ y[i] = v / H[i][i];
59
+ }
60
+ for (let i = 0; i < k; i++) for (let j = 0; j < N; j++) x[j] += y[i] * V[i][j];
61
+ if (converged) return true;
62
+ }
63
+ return false;
6
64
  }
7
65
 
8
66
  // Solve the K×K complex system (Ar + i Ai) x = (last column), partial pivoting by modulus.
@@ -37,8 +95,13 @@ function complexSolve(Ar, Ai, K) {
37
95
  }
38
96
 
39
97
  // 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 } = {}) {
98
+ // already preconditioned; x holds the starting guess and receives the answer. A cycle's own
99
+ // residual estimate can drift far from the true one (a preconditioner nearly singular at low
100
+ // frequency, or an op whose round-off the preconditioner amplifies), so each cycle is judged by
101
+ // the true residual, at the next one's start: it returns once that's below tol, and gives up when
102
+ // two cycles in a row fail to lower it, leaving x at the best iterate. Returns the true residuals (relative
103
+ // to b) at the start and at that iterate.
104
+ function gmres(op, b, x, { m = 12, restarts = 8, tol = 1e-8 } = {}) {
42
105
  const N = b[0].length;
43
106
  const cdot = (a, c) => {
44
107
  let re = 0, im = 0;
@@ -50,15 +113,24 @@ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
50
113
  };
51
114
  const norm = (a) => Math.sqrt(cdot(a, a)[0]);
52
115
  const bn = norm(b) || 1;
53
- for (let cycle = 0; cycle < restarts; cycle++) {
116
+ const best = [Float64Array.from(x[0]), Float64Array.from(x[1])];
117
+ let initial = Infinity, least = Infinity, idle = 0;
118
+ for (let cycle = 0; ; cycle++) {
54
119
  const ax = op(x), r = [Float64Array.from(b[0], (v, j) => v - ax[0][j]), Float64Array.from(b[1], (v, j) => v - ax[1][j])];
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)]];
120
+ const beta = norm(r) / bn;
121
+ if (cycle === 0) initial = beta;
122
+ idle = beta < least ? 0 : idle + 1;
123
+ if (idle === 0) {
124
+ least = beta;
125
+ best[0].set(x[0]);
126
+ best[1].set(x[1]);
127
+ }
128
+ if (!(beta > tol) || cycle === restarts || idle === 2) break;
129
+ const V = [[r[0].map((v) => v / (beta * bn)), r[1].map((v) => v / (beta * bn))]];
58
130
  const H = Array.from({ length: m + 1 }, () => Array.from({ length: m }, () => [0, 0]));
59
131
  const cs = [], sn = [], g = Array.from({ length: m + 1 }, () => [0, 0]);
60
- g[0] = [beta, 0];
61
- let k = 0, converged = false;
132
+ g[0] = [beta * bn, 0];
133
+ let k = 0;
62
134
  for (; k < m; k++) {
63
135
  const w = op(V[k]);
64
136
  for (let i = 0; i <= k; i++) {
@@ -89,7 +161,6 @@ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
89
161
  g[k + 1] = [-(s[0] * gr + s[1] * gi), -(s[0] * gi - s[1] * gr)];
90
162
  if (!(Math.hypot(g[k + 1][0], g[k + 1][1]) > tol * bn)) {
91
163
  k++;
92
- converged = true;
93
164
  break;
94
165
  }
95
166
  }
@@ -112,8 +183,10 @@ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
112
183
  x[1][j] += yr * V[i][1][j] + yi * V[i][0][j];
113
184
  }
114
185
  }
115
- if (converged) return; // (by its own residual estimate)
116
186
  }
187
+ x[0].set(best[0]);
188
+ x[1].set(best[1]);
189
+ return { initial, residual: least };
117
190
  }
118
191
 
119
192
  // Discretisation and nonlinear solver.
@@ -145,6 +218,7 @@ import { BlockTridiagonal, ComplexBlockTridiagonal } from './blockTridiagonal.js
145
218
  import { bernoulli, bernoulliDerivative } from './bernoulli.js';
146
219
  import { EPS0, FARADAY } from './constants.js';
147
220
  import { nextBreakpoint, sourceAt } from './device.js';
221
+ import { DifferenceTerms } from './difference.js';
148
222
  import { DeviceError } from './errors.js';
149
223
  import { powi, powr } from './pow.js';
150
224
 
@@ -224,10 +298,14 @@ export class Solver {
224
298
  model.ports.forEach((port, k) => {
225
299
  if (port.surface.length > 0) port.nodes.forEach((g, w) => ((this.surfPort[g] = k), (this.surfW[g] = w)));
226
300
  });
301
+ // Each surface species of each port, as a column of what's conserved: [port, species].
302
+ this.surfCols = model.ports.flatMap((port, k) => port.surface.map((_, q) => [k, q]));
303
+ this.surfColOf = model.ports.map((port, k) => this.surfCols.findIndex(([kk]) => kk === k));
227
304
  this.th = new Float64Array(nNodes * nSurf);
228
305
  this.thOld = new Float64Array(nNodes * nSurf);
229
- this.th0 = new Float64Array(nNodes); // the bare fraction θ₀, directly (1 − Σθ cancels as θ → 1)
230
- this.th0Old = new Float64Array(nNodes);
306
+ // The bare fraction θ₀, directly (1 − Σθ cancels as θ → 1); 1 where there's no surface.
307
+ this.th0 = new Float64Array(nNodes).fill(1);
308
+ this.th0Old = new Float64Array(nNodes).fill(1);
231
309
 
232
310
  // Per-node material data, flattened [g·n + i].
233
311
  this.present = new Uint8Array(nNodes * n);
@@ -337,6 +415,7 @@ export class Solver {
337
415
  this.time = 0;
338
416
  this.lastDt = Infinity;
339
417
  this.atSteady = false; // whether the state is a converged steady solve
418
+ this.lin = null; // (set while assembling for DifferenceTerms)
340
419
  // Contact bookkeeping, filled by assemble(): particle flux toward +x through each contact,
341
420
  // and the displacement there (the metal's surface charge for a neutral link).
342
421
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
@@ -348,6 +427,13 @@ export class Solver {
348
427
  this.portQStart = new Float64Array(model.ports.length);
349
428
  this.portQOld = new Float64Array(model.ports.length);
350
429
  this.portQEnd = null;
430
+ // And per node, the charge per volume the capacitances hold against the window (Σ aσ, over
431
+ // every port with one there: two gates on one channel, say; the ions hold −Σ aσ), its slope
432
+ // Σ a·C per volt, and the ports [terminal, index in its window].
433
+ this.qg = new Float64Array(grid.nNodes);
434
+ this.qgSlope = new Float64Array(grid.nNodes);
435
+ this.qgTerms = Array.from({ length: grid.nNodes }, () => []);
436
+ model.ports.forEach((port, k) => port.capacitance && port.nodes.forEach((g, w) => this.qgTerms[g].push([2 + k, w])));
351
437
  this.contactDOld = { left: 0, right: 0 };
352
438
  this.contactDStart = { left: 0, right: 0 };
353
439
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
@@ -402,12 +488,6 @@ export class Solver {
402
488
  );
403
489
  this.portRates = model.ports.map((port) => port.reactions.map(() => new Float64Array(port.nodes.length)));
404
490
 
405
- // Whether any region is strictly neutral (ε = 0, not a conductor): see integrate().
406
- this.strictlyNeutral = model.regions.some((reg) => {
407
- const mat = materials[reg.material];
408
- return !mat.conductor && mat.epsr === 0;
409
- });
410
-
411
491
  // Scales the generation reactions' rates during a steady solve's continuation (else 1).
412
492
  this.generationScale = 1;
413
493
  this.hasGeneration = this.rxs.some((rx) => rx.generation);
@@ -428,11 +508,15 @@ export class Solver {
428
508
  // A port that only exchanges (an O₂ supply, a leak) is solved there too: left out, its window
429
509
  // fell back on the ill-conditioned terms, and a closed neutral electrolyte whose potential is
430
510
  // set only by a face reaction couldn't take a step.
511
+ // An end node is solved there too if its contact is closed to every ion and leaves φ alone.
431
512
  const held = new Uint8Array(nNodes);
432
- held[0] = held[nNodes - 1] = 1;
513
+ const closed = (side) => model.contacts[side].phi.type === 'neutral' && model.contacts[side].species.every((l, i) => species[i].z === 0 ? l.type !== 'equilibrium' : l.type === 'blocked');
514
+ this.closedEnd = { left: closed('left'), right: closed('right') };
515
+ held[0] = this.closedEnd.left ? 0 : 1;
516
+ held[nNodes - 1] = this.closedEnd.right ? 0 : 1;
433
517
  for (const port of model.ports) if (port.species.some((l) => l.type === 'equilibrium')) for (const g of port.nodes) held[g] = 1;
434
518
  this.chargeNode = new Uint8Array(nNodes);
435
- const neutralFace = (f) => f >= 0 && f < model.interfaces.length && model.interfaces[f].phi.type === 'neutral';
519
+ const neutralFace = (f) => (f === -1 ? this.closedEnd.left : f === model.interfaces.length ? this.closedEnd.right : model.interfaces[f].phi.type === 'neutral');
436
520
  regions.forEach((reg, r) => {
437
521
  const mat = materials[reg.material];
438
522
  if (mat.conductor || mat.epsr !== 0) return;
@@ -443,6 +527,18 @@ export class Solver {
443
527
  }
444
528
  });
445
529
  this.chargeNodes = Int32Array.from([...this.chargeNode.keys()].filter((g) => this.chargeNode[g]));
530
+ // An end node of a strictly neutral region that its contact holds (leaving it neutral: φ law
531
+ // bulk or neutral): the contact's current is the ions' charge passing, and their storage
532
+ // carries none under neutrality, nor does the box's net charge change (a bulk law's "D").
533
+ // Read from the box's balance, both come with round-off ∝ c/dt that cancels only roughly,
534
+ // which on short steps swamps the flux (a floating bath on a dilute side rattled). So there,
535
+ // on a transient step, the ions' storage goes in only after the contact has read its current,
536
+ // and the box's charge isn't counted as displacement (see _contact).
537
+ this.lateStorage = new Uint8Array(nNodes);
538
+ [0, nNodes - 1].forEach((g, k) => {
539
+ const mat = materials[regions[grid.nodeRegion[g]].material], law = contacts[k === 0 ? 'left' : 'right'].phi.type;
540
+ if (!mat.conductor && mat.epsr === 0 && (law === 'bulk' || law === 'neutral') && !this.chargeNode[g] && !this.phiUndefined[g] && this.nodeIdeal[g]) this.lateStorage[g] = 1;
541
+ });
446
542
  this.combining = false;
447
543
  this.bookkeeping = regions.map((_, r) => {
448
544
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], nodes = [], segs = [];
@@ -456,6 +552,8 @@ export class Solver {
456
552
  this._findStretches();
457
553
  this.initFromComposition();
458
554
  this.referenceAmounts = this.stretches.map((st) => this.amount(st));
555
+ // And what each surface species held then (a conserved combination through one counts it).
556
+ this.surfaceRef = Float64Array.from(this.surfCols, (_, j) => this.surfaceAmount(j));
459
557
  // ∫ (flux in − flux out) dt through the contacts, per stretch, since the reference.
460
558
  this.boundaryIntake = new Float64Array(this.stretches.length);
461
559
  }
@@ -486,6 +584,11 @@ export class Solver {
486
584
  const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
487
585
  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]++;
488
586
  const sys = (this.sys = new BlockTridiagonal(nB, sizes));
587
+ // A pivot that cancels to exactly nothing (a population held only through conductances
588
+ // 1e-16 of its neighbours', as GaAs's minority carriers ~1 per m³ beside a face) is
589
+ // perturbed, not thrown on: Newton then converges, its small updates meaning small residuals
590
+ // (the factorisation only amplifies), refining its solves where they stall.
591
+ sys.staticPivots = true;
489
592
  const N = sys.size;
490
593
  this.SINK = N;
491
594
  const rix = (this.rix = new Int32Array(nB * M).fill(N)), fullOf = (this.fullOf = new Int32Array(N));
@@ -496,6 +599,7 @@ export class Solver {
496
599
  }
497
600
  this.res = new Float64Array(N + 1);
498
601
  this.delta = new Float64Array(N + 1);
602
+ this.rowScale = new Float64Array(N + 1).fill(1); // each row's scaling in the factorised system
499
603
  }
500
604
 
501
605
  _factor() {
@@ -545,6 +649,8 @@ export class Solver {
545
649
  if (reacting.length > 0) reactive = true;
546
650
  const connected = leftOpen || rightOpen || linked.length > 0;
547
651
  this.stretches.push({
652
+ leftOpen,
653
+ rightOpen,
548
654
  linked, // ports that hold or exchange it
549
655
  ports: [...new Set([...linked, ...reacting])], // ports that bring any in
550
656
  species: i,
@@ -575,6 +681,33 @@ export class Solver {
575
681
  st.mobile = true;
576
682
  for (let q = st.regions[0]; q <= st.regions[1]; q++) if (!(materials[regions[q].material].D[st.species] > 0)) st.mobile = false;
577
683
  }
684
+ // Stretches whose steady state is known outright: a species that no reaction or port touches,
685
+ // reached by one contact only (the other end blocked), carries no flux at steady state, so its
686
+ // level is flat at that contact's. Steady solves pin it there (_flatRows) rather than find it
687
+ // through its own conduction, which can be all but nothing: a MOS capacitor's inversion
688
+ // electrons reach the back contact only through a bulk with ~1e3 of them per cm³. (Not
689
+ // through flow or mixing, nor a concentrated material's cross-diffusion, where zero flux
690
+ // isn't a flat level.)
691
+ this.flatStretches = this.stretches.filter((st) => {
692
+ if (st.reactive || st.ports.length > 0 || !st.mobile || st.leftOpen === st.rightOpen) return false;
693
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
694
+ const reg = regions[q], mat = materials[reg.material];
695
+ if (mat.conductor || !mat.ideal || reg.velocity !== 0 || reg.mixing > 0) return false;
696
+ }
697
+ return true;
698
+ });
699
+ // Likewise a spectator that's mobile throughout: no flux at steady state, so its level is flat,
700
+ // at a value its amount fixes. Steady solves hold its levels equal (_levelRows) rather than
701
+ // find them through its own conduction: blocked SO₄²⁻ driven from a zinc cathode's extended
702
+ // space charge is down to 1e-25 mol/m³ there, and the chain of conductances through it lost
703
+ // the steady system 14 digits. (Within one region: across a face, the rows that would hold
704
+ // the level and pin the face's flux leave a diagonal block singular.)
705
+ this.levelStretches = this.stretches.filter((st) => {
706
+ if (!st.spectator || !st.mobile || st.ports.length > 0 || st.regions[0] !== st.regions[1]) return false;
707
+ const reg = regions[st.regions[0]], mat = materials[reg.material];
708
+ return !mat.conductor && mat.ideal && reg.velocity === 0 && !(reg.mixing > 0);
709
+ });
710
+ this.flattening = false;
578
711
  // Conserved amounts solved directly, each in place of one (redundant) balance row: a
579
712
  // spectator's own amount, and each conserved combination of reacting stretches (the total
580
713
  // iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose weighted balances sum to zero at steady state.
@@ -582,12 +715,14 @@ export class Solver {
582
715
  // its faces; both are left to giant time steps.
583
716
  this.constraints = [];
584
717
  const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
585
- const add = (parts, rowStretch) => {
718
+ const add = (parts, rowStretch, surface = []) => {
586
719
  const st = this.stretches[rowStretch];
587
- const nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
720
+ let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
721
+ for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
588
722
  for (const p of parts) this.stretches[p.stretch].conserved = true;
589
723
  this.constraints.push({
590
724
  parts,
725
+ surface,
591
726
  row: this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
592
727
  idx: new Int32Array(nNodes * this.M),
593
728
  w: new Float64Array(nNodes * this.M),
@@ -599,12 +734,15 @@ export class Solver {
599
734
  this.stretches.forEach((st, k) => {
600
735
  if (st.spectator && st.mobile) add([{ stretch: k, w: 1 }], k);
601
736
  });
737
+ const S = this.stretches.length;
602
738
  for (const w of this.moieties) {
603
- const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
604
- if (parts.length === 1 && this.stretches[parts[0].stretch].spectator) continue; // (above)
739
+ const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
740
+ const surface = [...w.keys()].filter((k) => k >= S && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
741
+ if (parts.length === 0) continue; // (a surface's own: left to giant time steps)
742
+ if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
605
743
  if (parts.some((p) => !this.stretches[p.stretch].mobile || conductor(this.stretches[p.stretch]))) continue;
606
744
  // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
607
- add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch);
745
+ add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch, surface);
608
746
  }
609
747
  // A combination of immobile stretches (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻) is conserved
610
748
  // node by node, since nothing carries it anywhere: at each node its weighted sum stays what
@@ -628,6 +766,66 @@ export class Solver {
628
766
  reference: new Float64Array(sts[0].nodes[1] - sts[0].nodes[0] + 1),
629
767
  });
630
768
  }
769
+ // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
770
+ // contact, port or reaction) holds its level only through its faces' fluxes, which can be
771
+ // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
772
+ // conducts): eliminated, the level is lost to round-off, and Newton settles at a wrong one with
773
+ // nothing to show for it. In steady solves the piece's balance summed over its nodes, where
774
+ // the internal fluxes cancel exactly and the faces' alone are left, replaces its first node's
775
+ // (see _applyIslands), once the plain solves converge (see newton).
776
+ this.islands = [];
777
+ this.pinnedIslands = [];
778
+ this.islandsOn = false; // (newton() turns them on, see there)
779
+ this.pins = [];
780
+ const used = new Set(this.constraints.map((cs) => cs.row));
781
+ for (const st of this.stretches) {
782
+ const i = st.species, [s0, s1] = st.regions;
783
+ if (!st.mobile || conductor(st)) continue;
784
+ for (let q0 = s0, q1 = s0; q1 <= s1; q1++) {
785
+ const p0 = q0;
786
+ q0 = q1 + 1;
787
+ if (p0 === s0 && q1 === s1) continue; // (uncut)
788
+ if ((p0 === s0 && st.leftOpen) || (q1 === s1 && st.rightOpen)) continue;
789
+ const inside = (q) => q >= p0 && q <= q1;
790
+ if (model.ports.some((port) => inside(port.region) && (port.species[i].type !== 'blocked' || port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i))))) continue;
791
+ let reactive = false;
792
+ for (let q = p0; q <= q1; q++) {
793
+ for (const rx of model.reactions) if (rx.kf[regions[q].material] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
794
+ }
795
+ for (let f = Math.max(0, p0 - 1); f <= Math.min(model.interfaces.length - 1, q1); f++) {
796
+ for (const rx of model.interfaces[f].reactions) if (rx.part.some((p) => p.i === i && inside(f + p.side))) reactive = true;
797
+ }
798
+ const row = this.blockOfNode[grid.regionStart[p0]] * this.M + 1 + i;
799
+ if (reactive || used.has(row)) continue;
800
+ used.add(row);
801
+ // The faces' fluxes into the piece: −A_f u at its left face, +A_f u at its right. Across
802
+ // a face that holds μ̄ level, u is set only by the edge balances, and eliminated, it can
803
+ // come out of the piece's own (G Δη, its two η all but equal: a neighbour 1e20 times
804
+ // less conductive, and the piece's level was seen through one face of two, and Newton
805
+ // swung it back and forth across both). There the outside edge node's balance is added:
806
+ // u cancels, and the outside's first segment carries the flux instead.
807
+ const faces = [], outside = [];
808
+ if (p0 > s0) faces.push([p0 - 1, -1, grid.regionEnd[p0 - 1]]);
809
+ if (q1 < s1) faces.push([q1, 1, grid.regionStart[q1 + 1]]);
810
+ for (const [f, , g] of faces) {
811
+ if (model.interfaces[f].links[i].type !== 'equilibrium' || g === 0 || g === this.nNodes - 1) continue;
812
+ if (model.ports.some((port) => port.nodes.includes(g))) continue;
813
+ outside.push(this.blockOfNode[g] * this.M + 1 + i);
814
+ }
815
+ this.islands.push({
816
+ stretch: st,
817
+ row,
818
+ flux: faces.map(([f]) => this.blockOfFace[f] * this.M + 1 + i),
819
+ weight: faces.map(([f, sg]) => sg * grid.area[grid.regionEnd[f]]),
820
+ outside,
821
+ idx: new Int32Array(faces.length + 3 * this.M * outside.length),
822
+ w: new Float64Array(faces.length + 3 * this.M * outside.length),
823
+ len: 0,
824
+ res: 0,
825
+ });
826
+ }
827
+ }
828
+ for (const isl of this.islands) isl.outside = isl.outside.filter((o) => !used.has(o));
631
829
  this.constrained = false;
632
830
  }
633
831
 
@@ -644,6 +842,71 @@ export class Solver {
644
842
  }
645
843
  }
646
844
 
845
+ // The islands' summed balances (steady solves only): Σ A_f u over the faces into each, its
846
+ // first node's row pinned in the factorised matrix and the sum bordered (_solveBordered) like a
847
+ // conserved amount's. The pieces of a stretch held flat by its contact are left as they are.
848
+ _applyIslands() {
849
+ const { u, res } = this, R = this.rix, out = [];
850
+ for (const isl of this.islands) {
851
+ if (this.flattening && this.flatStretches.includes(isl.stretch)) continue;
852
+ const b0 = Math.floor(isl.row / this.M), r0 = isl.row % this.M;
853
+ if (this.loc[isl.row] < 0) continue;
854
+ // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
855
+ // exactly where they meet), then the nonzeros kept.
856
+ const acc = this.islandRow ?? (this.islandRow = new Float64Array(this.sys.size + 1));
857
+ const cols = [];
858
+ const add = (col, v) => {
859
+ if (acc[col] === 0) cols.push(col);
860
+ acc[col] += v;
861
+ };
862
+ let t = 0;
863
+ isl.flux.forEach((o, j) => {
864
+ if (this.loc[o] < 0) return;
865
+ t += isl.weight[j] * u[o];
866
+ add(R[o], isl.weight[j]);
867
+ });
868
+ for (const o of isl.outside) {
869
+ if (this.loc[o] < 0) continue;
870
+ t += res[R[o]];
871
+ this._rowInto(o, add);
872
+ }
873
+ let len = 0;
874
+ for (const col of cols) {
875
+ if (col < this.sys.size && acc[col] !== 0) {
876
+ isl.idx[len] = col;
877
+ isl.w[len++] = acc[col];
878
+ }
879
+ acc[col] = 0;
880
+ }
881
+ isl.len = len;
882
+ isl.res = t;
883
+ this._replaceRow(b0, r0);
884
+ for (const B of this.termB) B[R[isl.row]] = 0;
885
+ this._j(b0, r0, b0, r0, 1);
886
+ res[R[isl.row]] = 0;
887
+ out.push(isl);
888
+ }
889
+ return out;
890
+ }
891
+
892
+ // Row o's Jacobian entries, as add(compact column, value): the assembled ones, and the dilute
893
+ // kernels' kept aside in difference form (see _assembleDifference) when they are.
894
+ _rowInto(o, add) {
895
+ const { sys, M, nB } = this, b = Math.floor(o / M), l = this.loc[o];
896
+ const { A, B, C, sizes, offA, offB, offC, offX } = sys, m = sizes[b];
897
+ for (let k = 0; k < m; k++) if (B[offB[b] + l * m + k] !== 0) add(offX[b] + k, B[offB[b] + l * m + k]);
898
+ if (b > 0) for (let k = 0, mp = sizes[b - 1]; k < mp; k++) if (A[offA[b] + l * mp + k] !== 0) add(offX[b - 1] + k, A[offA[b] + l * mp + k]);
899
+ if (b < nB - 1) for (let k = 0, mn = sizes[b + 1]; k < mn; k++) if (C[offC[b] + l * mn + k] !== 0) add(offX[b + 1] + k, C[offC[b] + l * mn + k]);
900
+ if (this.lin) {
901
+ const into = this.linRow ?? (this.linRow = new Float64Array(sys.size + 1));
902
+ this.lin.capture(this.rix[o], 1, 0, into);
903
+ for (let k = 0; k < sys.size; k++) {
904
+ if (into[k] !== 0) add(k, into[k]);
905
+ into[k] = 0;
906
+ }
907
+ }
908
+ }
909
+
647
910
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
648
911
  _applyLocalConstraints() {
649
912
  const { n, M, c, res, dA: d } = this;
@@ -667,26 +930,32 @@ export class Solver {
667
930
  // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
668
931
  // for each stretch fed from outside. Exact integer data, so plain elimination will do.
669
932
  _conservedMoieties() {
670
- const { model, n } = this, S = this.stretches.length;
933
+ const { model, n } = this, S = this.stretches.length, C = S + this.surfCols.length;
671
934
  const rows = [];
672
935
  model.interfaces.forEach((itf, f) => {
673
936
  for (const rx of itf.reactions) {
674
- const row = new Float64Array(S);
937
+ const row = new Float64Array(C);
675
938
  for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
676
939
  rows.push(row);
677
940
  }
678
941
  });
679
- for (const port of model.ports) {
942
+ // An electrode surface's species are columns too, after the stretches (S + j for surface
943
+ // column j), so a combination that passes through them (A⁺ + e⁻ = S, S + e⁻ = B⁻) is conserved
944
+ // with what the surface holds.
945
+ model.ports.forEach((port, k) => {
680
946
  for (const rx of port.reactions) {
681
- const row = new Float64Array(S); // (the electrode's carrier is outside the device)
682
- for (const p of rx.part) if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
947
+ const row = new Float64Array(C); // (the electrode's carrier is outside the device)
948
+ for (const p of rx.part) {
949
+ if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
950
+ else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
951
+ }
683
952
  rows.push(row);
684
953
  }
685
- }
954
+ });
686
955
  model.regions.forEach((reg, q) => {
687
956
  for (const rx of model.reactions) {
688
957
  if (!(rx.kf[reg.material] > 0)) continue;
689
- const row = new Float64Array(S);
958
+ const row = new Float64Array(C);
690
959
  for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
691
960
  for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
692
961
  rows.push(row);
@@ -694,34 +963,34 @@ export class Solver {
694
963
  });
695
964
  this.stretches.forEach((st, k) => {
696
965
  if (!st.contactFed) return;
697
- const row = new Float64Array(S);
966
+ const row = new Float64Array(C);
698
967
  row[k] = 1;
699
968
  rows.push(row);
700
969
  });
701
970
  // Reduced row echelon form; the free columns give the null space.
702
971
  const pivots = [];
703
972
  let r = 0;
704
- for (let col = 0; col < S && r < rows.length; col++) {
973
+ for (let col = 0; col < C && r < rows.length; col++) {
705
974
  let best = r;
706
975
  for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
707
976
  if (Math.abs(rows[best][col]) < 1e-9) continue;
708
977
  [rows[r], rows[best]] = [rows[best], rows[r]];
709
978
  const pr = rows[r], pv = pr[col];
710
- for (let j = 0; j < S; j++) pr[j] /= pv;
979
+ for (let j = 0; j < C; j++) pr[j] /= pv;
711
980
  for (let i = 0; i < rows.length; i++) {
712
981
  if (i === r || rows[i][col] === 0) continue;
713
982
  const fct = rows[i][col];
714
- for (let j = 0; j < S; j++) rows[i][j] -= fct * pr[j];
983
+ for (let j = 0; j < C; j++) rows[i][j] -= fct * pr[j];
715
984
  }
716
985
  pivots.push(col);
717
986
  r++;
718
987
  }
719
- const isPivot = new Uint8Array(S);
988
+ const isPivot = new Uint8Array(C);
720
989
  for (const col of pivots) isPivot[col] = 1;
721
990
  const basis = [];
722
- for (let free = 0; free < S; free++) {
991
+ for (let free = 0; free < C; free++) {
723
992
  if (isPivot[free]) continue;
724
- const w = new Float64Array(S);
993
+ const w = new Float64Array(C);
725
994
  w[free] = 1;
726
995
  pivots.forEach((col, i) => {
727
996
  const v = -rows[i][free];
@@ -761,6 +1030,19 @@ export class Solver {
761
1030
  }
762
1031
  }
763
1032
  }
1033
+ // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
1034
+ for (const { col, w } of cs.surface) {
1035
+ const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
1036
+ reference += w * this.surfaceRef[col];
1037
+ port.nodes.forEach((g, j) => {
1038
+ const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
1039
+ amount += f * tq;
1040
+ for (let x = 0; x < ns; x++) {
1041
+ cs.idx[len] = this.rix[b * M + 1 + n + x];
1042
+ cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
1043
+ }
1044
+ });
1045
+ }
764
1046
  cs.len = len;
765
1047
  cs.res = amount - reference;
766
1048
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
@@ -772,7 +1054,8 @@ export class Solver {
772
1054
 
773
1055
  // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
774
1056
  // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
775
- // the spectators' conservation rows (pinned in T, see _applyConstraints). By low-rank
1057
+ // the rows pinned in T: the spectators' conserved amounts (see _applyConstraints) and the
1058
+ // islands' summed balances (see _applyIslands). By low-rank
776
1059
  // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
777
1060
  // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
778
1061
  // system for the μ (pins) and δV (terminals):
@@ -839,6 +1122,21 @@ export class Solver {
839
1122
  }
840
1123
 
841
1124
 
1125
+ // An electrode surface at its starting coverages at node g: η = μ°/RT + ln(θ/θ₀).
1126
+ _surfaceStart(g) {
1127
+ const { n, M, u, model } = this, surf = model.ports[this.surfPort[g]].surface, b = this.blockOfNode[g];
1128
+ const bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1129
+ surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1130
+ }
1131
+
1132
+ /** Amount (mol per unit area) of surface column j's species, Γ Σ v·a·θ, in the current state. */
1133
+ surfaceAmount(j) {
1134
+ const [k, q] = this.surfCols[j], port = this.model.ports[k], vol = this.model.grid.vol;
1135
+ let s = 0;
1136
+ port.nodes.forEach((g, w) => (s += vol[g] * this.portArea[k][w] * this.th[g * this.nSurf + q]));
1137
+ return port.surface[0].capacity * s;
1138
+ }
1139
+
842
1140
  /** Total amount (mol per unit area) of a stretch's species in the current state. */
843
1141
  amount(stretch) {
844
1142
  const { n, c, model } = this;
@@ -1078,20 +1376,30 @@ export class Solver {
1078
1376
  const shift = level(t.side) - level(this.terms[held].side);
1079
1377
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1080
1378
  }
1081
- // Electrode surfaces at their starting coverages: η = μ°/RT + ln(θ/θ₀).
1082
- for (let g = 0; g < this.nNodes; g++) {
1083
- const k = this.surfPort[g];
1084
- if (k < 0) continue;
1085
- const surf = model.ports[k].surface, b = this.blockOfNode[g], bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1086
- surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1087
- }
1379
+ for (let g = 0; g < this.nNodes; g++) if (this.surfPort[g] >= 0) this._surfaceStart(g);
1088
1380
  this.computeConcentrations();
1089
- // A floating electrode spread through a port starts where its reactions pass the current it's
1090
- // set (none, behind a resistance): at its mixed potential in the start's composition, not
1091
- // level with a held terminal, which can be volts away and pass an absurd current.
1381
+ // A floating capacitance starts uncharged (σ = 0 at the window's mean φ), as the starting
1382
+ // composition, neutral without it, assumes; with reactions too, its double layer then charges
1383
+ // toward their mixed potential.
1384
+ for (const k of this.floating) {
1385
+ const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1386
+ if (!port?.capacitance) continue;
1387
+ let w = 0, sum = 0;
1388
+ port.nodes.forEach((g, j) => {
1389
+ if (this.phiUndefined[g]) return;
1390
+ const a = this.model.grid.vol[g] * this.portArea[t.index][j];
1391
+ w += a;
1392
+ sum += a * this.VT * u[this.blockOfNode[g] * M];
1393
+ });
1394
+ if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1395
+ }
1396
+ // A floating electrode spread through a port (without a double layer) starts where its
1397
+ // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
1398
+ // the start's composition, not level with a held terminal, which can be volts away and pass
1399
+ // an absurd current.
1092
1400
  for (const k of this.floating) {
1093
1401
  const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1094
- if (!port || port.reactions.length === 0) continue;
1402
+ if (!port || port.reactions.length === 0 || port.capacitance) continue;
1095
1403
  const want = t.drive.kind === 'I' ? sourceAt(t.drive.src, this.time) : 0;
1096
1404
  const current = (V) => {
1097
1405
  this.termV[k] = V;
@@ -1130,8 +1438,7 @@ export class Solver {
1130
1438
  pref *= p.side === 2 ? Math.exp(e * (u[o] + uLo[o] - port.surface[p.s].mu0 / RT)) : powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1131
1439
  }
1132
1440
  if (rx.bare) pref *= this.th0[g];
1133
- // (far from equilibrium, the plain difference: bvFactor's product would give 0·∞ there)
1134
- return pref * (Math.abs(a) > 50 ? Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a) : bvFactor(a, rx.alpha).g);
1441
+ return pref * bvFactor(a, rx.alpha).g;
1135
1442
  }
1136
1443
 
1137
1444
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
@@ -1294,6 +1601,7 @@ export class Solver {
1294
1601
  const { grid, materials, regions } = model;
1295
1602
  sys.clear();
1296
1603
  res.fill(0);
1604
+ if (this.lin) this.lin.reset(1 / dt);
1297
1605
  this.computeConcentrations();
1298
1606
 
1299
1607
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -1325,8 +1633,13 @@ export class Solver {
1325
1633
  // Terminals: ports (after every other term at their nodes, so a held level can read its
1326
1634
  // flux), then contacts, then the circuit rows of the floating ones.
1327
1635
  this._terminals(dt);
1636
+ if (this.flattening && dt === Infinity) this._flatRows();
1637
+ if (this.flattening && this.constrained && dt === Infinity) this._levelRows();
1328
1638
  if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1329
1639
  if (this.constrained && dt === Infinity && this.localConstraints.length > 0) this._applyLocalConstraints();
1640
+ // Rows kept aside for _solveBordered: the conserved amounts' and the islands'.
1641
+ this.pinnedIslands = this.constrained && dt === Infinity && this.islandsOn ? this._applyIslands() : [];
1642
+ this.pins = this.constrained && dt === Infinity ? [...this.constraints, ...this.pinnedIslands] : [];
1330
1643
  this.transformed = this.combining && dt !== Infinity;
1331
1644
  if (this.transformed) this._chargeRows(dt);
1332
1645
  }
@@ -1346,7 +1659,7 @@ export class Solver {
1346
1659
  // charge in the Gauss row, written straight into the diagonal blocks by local index. A
1347
1660
  // dielectric is the case with no species.
1348
1661
  _nodesDilute(g0, g1, dt) {
1349
- const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode } = this, R = this.rix, F = FARADAY;
1662
+ const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode, lin } = this, R = this.rix, F = FARADAY;
1350
1663
  const JB = sys.B, sizes = sys.sizes, offB = sys.offB, vol = this.model.grid.vol;
1351
1664
  for (let g = g0; g <= g1; g++) {
1352
1665
  const b = blockOfNode[g], v = vol[g];
@@ -1357,22 +1670,27 @@ export class Solver {
1357
1670
  continue;
1358
1671
  }
1359
1672
  let q = rhoFixed[g], dq = 0;
1673
+ const late = this.combining && this.lateStorage[g] === 1 && dt !== Infinity; // (the ions' storage: in _contact)
1360
1674
  for (let i = 0; i < n; i++) {
1361
1675
  const k = g * n + i;
1362
1676
  if (!present[k]) continue;
1363
- const ck = c[k], l = loc[lb + 1 + i];
1364
- res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
1365
- JB[oB + l * m + l] += (v * ck) / dt;
1677
+ const ck = c[k], l = loc[lb + 1 + i], stores = !(late && z[i] !== 0);
1678
+ if (stores) res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
1366
1679
  q += F * z[i] * ck;
1367
1680
  dq += F * z[i] * z[i] * ck;
1368
- if (p >= 0 && z[i] !== 0) {
1369
- JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
1370
- JB[oB + p * m + l] += -v * F * z[i] * ck;
1681
+ if (lin) {
1682
+ lin.node(R[lb + 1 + i], R[lb], z[i], stores ? v * ck : 0, -v * F * z[i] * ck);
1683
+ continue;
1684
+ }
1685
+ if (stores) {
1686
+ JB[oB + l * m + l] += (v * ck) / dt;
1687
+ if (p >= 0 && z[i] !== 0) JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
1371
1688
  }
1689
+ if (p >= 0 && z[i] !== 0) JB[oB + p * m + l] += -v * F * z[i] * ck;
1372
1690
  }
1373
1691
  if (p >= 0) {
1374
1692
  res[R[lb]] -= v * q;
1375
- JB[oB + p * m + p] += v * dq;
1693
+ if (!lin) JB[oB + p * m + p] += v * dq;
1376
1694
  }
1377
1695
  }
1378
1696
  }
@@ -1459,6 +1777,7 @@ export class Solver {
1459
1777
  for (let q = 0, o = offC[b] + lk * mn, s = offC[b] + li * mn; q < mn; q++) C[o + q] += w * C[s + q];
1460
1778
  res[rk] += w * res[ri];
1461
1779
  for (let t = 0; t < termB.length; t++) termB[t][rk] += w * termB[t][ri];
1780
+ if (this.lin) this.lin.combine(rk, ri, w);
1462
1781
  }
1463
1782
  // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
1464
1783
  // unknowns appear (its own rows, its neighbours', a terminal's current).
@@ -1494,6 +1813,18 @@ export class Solver {
1494
1813
  }
1495
1814
  }
1496
1815
  res[R[lb]] -= v * q;
1816
+ // The pivot's row (charge continuity) left storage out, the ions' net charge being constant
1817
+ // under neutrality. Against a capacitance it isn't: neutrality makes it −(aσ + ρ_fixed)/F,
1818
+ // so their storage is that less what the step started with, Σ z c_old, over dt (in units
1819
+ // of the pivot's balance).
1820
+ if (this.qgTerms[g].length > 0) {
1821
+ const f = -v / (FARADAY * z[k] * dt);
1822
+ let qOld = this.rhoFixed[g];
1823
+ for (let i = 0; i < n; i++) if (present[gn + i]) qOld += F * z[i] * cOld[gn + i];
1824
+ res[rk] += f * (this.qg[g] + qOld);
1825
+ B[offB[b] + lk * m + p] += -f * this.qgSlope[g] * this.VT;
1826
+ for (const [kq, w] of this.qgTerms[g]) termB[kq][rk] += f * this.portArea[kq - 2][w] * this.model.ports[kq - 2].capacitance.C;
1827
+ }
1497
1828
  }
1498
1829
  }
1499
1830
 
@@ -1573,6 +1904,7 @@ export class Solver {
1573
1904
  const mL = sys.sizes[bL], mR = sys.sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1574
1905
  res[R[bL * M]] += D;
1575
1906
  res[R[bR * M]] -= D;
1907
+ if (this.lin) return this.lin.displacement(R[bL * M], R[bR * M], k);
1576
1908
  sys.B[sys.offB[bL] + pL * mL + pL] += k;
1577
1909
  sys.C[sys.offC[bL] + pL * mR + pR] -= k;
1578
1910
  sys.A[sys.offA[bR] + pR * mL + pL] -= k;
@@ -1586,7 +1918,7 @@ export class Solver {
1586
1918
  // fluxes (e.g. majority carriers carrying a small current) don't vanish in the cancellation of
1587
1919
  // two huge drift and diffusion terms.
1588
1920
  _segmentsDilute(g0, g1, region, mat, vel) {
1589
- const { n, M, u, uLo, res, c, z, sys, loc } = this, R = this.rix, F = FARADAY;
1921
+ const { n, M, u, uLo, res, c, z, sys, loc, lin } = this, R = this.rix;
1590
1922
  const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1591
1923
  const { segLength, segArea } = this.model.grid;
1592
1924
  for (let s = g0; s < g1; s++) {
@@ -1611,6 +1943,10 @@ export class Solver {
1611
1943
  const dNdPhiR = zi * dNdd;
1612
1944
  res[R[bL * M + r]] += N;
1613
1945
  res[R[bR * M + r]] -= N;
1946
+ if (lin) {
1947
+ lin.segment(R[bL * M + r], R[bR * M + r], R[bL * M], R[bR * M], zi, -gBc * E, dNdEtaR, zi * dNdd);
1948
+ continue;
1949
+ }
1614
1950
  // Row r of block bL (couplings to itself in B, to bR in C) and of bR (to bL in A).
1615
1951
  const lL = loc[bL * M + r], lR = loc[bR * M + r];
1616
1952
  const oBL = offB[bL] + lL * mL, oCL = offC[bL] + lL * mR, oAR = offA[bR] + lR * mL, oBR = offB[bR] + lR * mR;
@@ -2019,7 +2355,10 @@ export class Solver {
2019
2355
  this.termDI[k] = 0;
2020
2356
  this.termI[k] = 0;
2021
2357
  }
2022
- this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt));
2358
+ this.qg.fill(0);
2359
+ this.qgSlope.fill(0);
2360
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
2361
+ this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, true));
2023
2362
  this._contact('left', dt);
2024
2363
  this._contact('right', dt);
2025
2364
  for (const k of this.floating) this._circuit(k);
@@ -2040,6 +2379,7 @@ export class Solver {
2040
2379
  _captureRow(b, s, w, into) {
2041
2380
  const l = this.loc[b * this.M + s];
2042
2381
  if (l < 0 || w === 0) return;
2382
+ if (this.lin) this.lin.capture(this.rix[b * this.M + s], w, this.lin.rate, into);
2043
2383
  const { sys, nB } = this, { sizes, offA, offB, offC, offX } = sys, m = sizes[b];
2044
2384
  for (const [X, nb, off] of [['A', b - 1, offA], ['B', b, offB], ['C', b + 1, offC]]) {
2045
2385
  if (nb < 0 || nb >= nB) continue;
@@ -2051,16 +2391,18 @@ export class Solver {
2051
2391
 
2052
2392
  // An internal port (terminal k): its exchange with each node of its window, as a source per
2053
2393
  // volume. A held ('equilibrium') level replaces the node's balance row; the source is then that
2054
- // row's residual, read just before. Its current into the device is Σ z F × the sources.
2055
- _port(port, flux, k, dt) {
2394
+ // row's residual, read just before. Its current into the device is Σ z F × the sources. In two
2395
+ // passes over the ports: everything else, then the held levels (`holds`), so that a level reads
2396
+ // every other port's terms at its nodes, and none is added into its row after.
2397
+ _port(port, flux, k, dt, holds) {
2056
2398
  const R = this.rix;
2057
2399
  const { n, M, u, uLo, res, z, VT } = this;
2058
2400
  const F = FARADAY, vol = this.model.grid.vol;
2059
2401
  const B = this.termB[k], C = this.termC[k];
2060
- flux.fill(0);
2402
+ if (!holds) flux.fill(0);
2061
2403
  for (let i = 0; i < n; i++) {
2062
2404
  const link = port.species[i];
2063
- if (link.type === 'blocked') continue;
2405
+ if (link.type === 'blocked' || (link.type === 'equilibrium') !== holds) continue;
2064
2406
  const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
2065
2407
  for (const g of port.nodes) {
2066
2408
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
@@ -2087,10 +2429,13 @@ export class Solver {
2087
2429
  }
2088
2430
  }
2089
2431
  }
2432
+ if (holds) {
2433
+ for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2434
+ return;
2435
+ }
2090
2436
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2091
2437
  if (port.capacitance) this._portCapacitance(port, k, dt);
2092
2438
  if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2093
- for (let i = 0; i < n; i++) this.termI[k] += z[i] * F * flux[i];
2094
2439
  }
2095
2440
 
2096
2441
  // A port's reaction at each node of its window: Butler–Volmer per area, as at a face, against
@@ -2167,6 +2512,8 @@ export class Solver {
2167
2512
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
2168
2513
  const b = this.blockOfNode[g], s = vol[g] * area[w] * Cs;
2169
2514
  const q = s * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2515
+ this.qg[g] += area[w] * Cs * (V - zeroCharge - VT * (u[b * M] + uLo[b * M]));
2516
+ this.qgSlope[g] += area[w] * Cs;
2170
2517
  Q += q;
2171
2518
  dQdV += s;
2172
2519
  res[R[b * M]] -= q;
@@ -2221,10 +2568,13 @@ export class Solver {
2221
2568
  const Ac = model.grid.area[g]; // the links' laws are per area: what they pass is that times A here
2222
2569
  const dyn = Number.isFinite(dt);
2223
2570
 
2224
- // Before anything is added, each balance residual is the flux into the device here.
2571
+ // Before anything is added, each balance residual is the flux into the device here. (A
2572
+ // closed end solved in charge rows has its ions' storage left out until later: none of them
2573
+ // passes, so their flux is zero.)
2574
+ const closedIons = this.combining && dyn && this.chargeNode[g] === 1;
2225
2575
  let I = 0;
2226
2576
  for (let i = 0; i < n; i++) {
2227
- const active = this.loc[b * M + 1 + i] >= 0;
2577
+ const active = this.loc[b * M + 1 + i] >= 0 && !(closedIons && z[i] !== 0);
2228
2578
  const nIn = active ? res[R[b * M + 1 + i]] : 0;
2229
2579
  flux[i] = sgn * nIn; // (toward +x)
2230
2580
  if (active && z[i] !== 0) {
@@ -2232,6 +2582,19 @@ export class Solver {
2232
2582
  this._captureRow(b, 1 + i, z[i] * F, C);
2233
2583
  }
2234
2584
  }
2585
+ // The ions' storage, left out until now (see lateStorage): in each balance, and in what
2586
+ // each species brings in, but not in the current.
2587
+ if (this.combining && dyn && this.lateStorage[g] === 1) {
2588
+ const v = model.grid.vol[g], { cOld } = this;
2589
+ for (let i = 0; i < n; i++) {
2590
+ if (z[i] === 0 || this.loc[b * M + 1 + i] < 0) continue;
2591
+ const ci = c[g * n + i], st = (v * (ci - cOld[g * n + i])) / dt;
2592
+ res[R[b * M + 1 + i]] += st;
2593
+ flux[i] += sgn * st;
2594
+ this._j(b, 1 + i, b, 1 + i, (v * ci) / dt);
2595
+ this._j(b, 1 + i, b, 0, (-v * z[i] * ci) / dt);
2596
+ }
2597
+ }
2235
2598
 
2236
2599
  // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2237
2600
  // links (neutral species): N_in = k (μ_out − μ)/RT.
@@ -2281,7 +2644,7 @@ export class Solver {
2281
2644
  // The Poisson residual is the outside's charge, D_in.
2282
2645
  const Din = res[R[b * M]];
2283
2646
  this.contactD[side] = sgn * Din;
2284
- if (dyn) {
2647
+ if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2285
2648
  I += (Din - Dstart) / dt;
2286
2649
  this._captureRow(b, 0, 1 / dt, C);
2287
2650
  }
@@ -2314,10 +2677,59 @@ export class Solver {
2314
2677
 
2315
2678
 
2316
2679
 
2680
+ // Steady solves: each flat stretch's level held at its contact's throughout (see
2681
+ // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
2682
+ _flatRows() {
2683
+ const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
2684
+ for (const st of this.flatStretches) {
2685
+ const i = st.species, side = st.leftOpen ? 'left' : 'right', k = side === 'left' ? 0 : 1;
2686
+ const link = model.contacts[side].species[i];
2687
+ const level = z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT;
2688
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
2689
+ const b = this.blockOfNode[g], o = b * M + 1 + i;
2690
+ if (this.loc[o] < 0) continue;
2691
+ this._replaceRow(b, 1 + i);
2692
+ for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
2693
+ this._j(b, 1 + i, b, 1 + i, 1);
2694
+ res[R[o]] = u[o] + uLo[o] - level;
2695
+ if (z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
2696
+ }
2697
+ // A face inside the stretch whose link holds the level continuous carries the species' flux
2698
+ // as an unknown that only the edge balances, now replaced, determined: it's zero.
2699
+ for (let f = st.regions[0]; f < st.regions[1]; f++) {
2700
+ if (model.interfaces[f].links[i].type !== 'equilibrium') continue;
2701
+ const b = this.blockOfFace[f], o = b * M + 1 + i;
2702
+ if (this.loc[o] < 0) continue;
2703
+ this._replaceRow(b, 1 + i);
2704
+ for (const B of this.termB) B[R[o]] = 0;
2705
+ this._j(b, 1 + i, b, 1 + i, 1);
2706
+ res[R[o]] = u[o] + uLo[o];
2707
+ }
2708
+ }
2709
+ }
2710
+
2317
2711
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
2712
+ // Steady solves: each level stretch's levels held equal (see levelStretches), node to node in
2713
+ // place of the balances. The first node's row is the amount's (see _applyConstraints).
2714
+ _levelRows() {
2715
+ const { M, u, uLo, res } = this, R = this.rix;
2716
+ for (const st of this.levelStretches) {
2717
+ const i = st.species;
2718
+ for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
2719
+ const b = this.blockOfNode[g], o = b * M + 1 + i, p = o - M;
2720
+ if (this.loc[o] < 0 || this.loc[p] < 0) continue;
2721
+ this._replaceRow(b, 1 + i);
2722
+ for (const B of this.termB) B[R[o]] = 0;
2723
+ this._j(b, 1 + i, b, 1 + i, 1);
2724
+ this._j(b, 1 + i, b - 1, 1 + i, -1);
2725
+ res[R[o]] = u[o] + uLo[o] - (u[p] + uLo[p]);
2726
+ }
2727
+ }
2728
+ }
2318
2729
  _replaceRow(b, rs) {
2319
2730
  const l = this.loc[b * this.M + rs];
2320
2731
  if (l < 0) return;
2732
+ if (this.lin) this.lin.kill(this.rix[b * this.M + rs]);
2321
2733
  const { sys, nB } = this, sz = sys.sizes, m = sz[b];
2322
2734
  sys.B.fill(0, sys.offB[b] + l * m, sys.offB[b] + (l + 1) * m);
2323
2735
  if (b > 0) sys.A.fill(0, sys.offA[b] + l * sz[b - 1], sys.offA[b] + (l + 1) * sz[b - 1]);
@@ -2336,8 +2748,12 @@ export class Solver {
2336
2748
  for (let k = 0; k < mp; k++) mx = Math.max(mx, Math.abs(A[oa + k]));
2337
2749
  for (let k = 0; k < m; k++) mx = Math.max(mx, Math.abs(B[ob + k]));
2338
2750
  for (let k = 0; k < mn; k++) mx = Math.max(mx, Math.abs(C[oc + k]));
2339
- if (mx === 0) continue;
2751
+ if (mx === 0) {
2752
+ this.rowScale[offX[b] + r] = 1;
2753
+ continue;
2754
+ }
2340
2755
  const s = 1 / mx;
2756
+ this.rowScale[offX[b] + r] = s;
2341
2757
  for (let k = 0; k < mp; k++) A[oa + k] *= s;
2342
2758
  for (let k = 0; k < m; k++) B[ob + k] *= s;
2343
2759
  for (let k = 0; k < mn; k++) C[oc + k] *= s;
@@ -2347,6 +2763,111 @@ export class Solver {
2347
2763
  }
2348
2764
  }
2349
2765
 
2766
+ // The Jacobian at dt with the dilute kernels' terms kept aside in difference form (see
2767
+ // DifferenceTerms), the rest assembled into a matrix of its own. The residual and terminal terms
2768
+ // come out as from assemble(dt); the Newton matrix is left as it was.
2769
+ _assembleDifference(dt) {
2770
+ const keep = this.sys;
2771
+ const rest = this.sysRest ?? (this.sysRest = new BlockTridiagonal(keep.n, keep.sizes));
2772
+ const lin = this.diffTerms ?? (this.diffTerms = new DifferenceTerms(keep.size, this.nNodes * this.n, this.nNodes * this.n));
2773
+ this.sys = rest;
2774
+ this.lin = lin;
2775
+ try {
2776
+ this.assemble(dt);
2777
+ } finally {
2778
+ this.sys = keep;
2779
+ this.lin = null;
2780
+ }
2781
+ return { rest, lin };
2782
+ }
2783
+
2784
+ // A Newton solve refined by GMRES: the factorised Jacobian as the preconditioner, and J·x
2785
+ // exact, with the dilute kernels' terms in difference form (see DifferenceTerms), as the
2786
+ // impedance does. The assembled J holds a flux's dependence on η at its two ends as two
2787
+ // entries; where they're huge (an inversion layer's 0.1 nm cells, a conductance ~1e11 against
2788
+ // the layer's own storage and the trickle from the bulk), eliminating them loses the layer's
2789
+ // overall level to round-off, and Newton rattles there. delta and deltaV come in as the plain
2790
+ // solve's and leave refined (in the transformed unknowns, as _solveBordered gives them); false
2791
+ // if refining didn't help, leaving them as they came.
2792
+ _refine(dt, delta, deltaV) {
2793
+ const { res, termRes, termB, termC, termDI, rowScale, floating: fl } = this;
2794
+ const N = this.sys.size, K = fl.length, pins = this.pins, R = this.rix;
2795
+ const save = { res: Float64Array.from(res), termRes: Float64Array.from(termRes), termB: termB.map((b) => Float64Array.from(b)), termC: termC.map((c) => Float64Array.from(c)), termDI: Float64Array.from(termDI), transformed: this.transformed, pinRes: this.pins.map((p) => p.res) };
2796
+ const restoreArrays = () => {
2797
+ res.set(save.res);
2798
+ termRes.set(save.termRes);
2799
+ save.termB.forEach((b, k) => termB[k].set(b));
2800
+ save.termC.forEach((c, k) => termC[k].set(c));
2801
+ termDI.set(save.termDI);
2802
+ this.transformed = save.transformed;
2803
+ this.pins.forEach((p, a) => (p.res = save.pinRes[a]));
2804
+ };
2805
+ // The same system, its terms kept apart: the rest, and each floating terminal's (unscaled).
2806
+ const { rest, lin } = this._assembleDifference(dt);
2807
+ const Bk = fl.map((k) => Float64Array.from(termB[k])), Ck = fl.map((k) => Float64Array.from(termC[k])), Dk = fl.map((k) => termDI[k]);
2808
+ restoreArrays();
2809
+ const v = new Float64Array(N + 1), jv = new Float64Array(N);
2810
+ const op = (x) => {
2811
+ for (let j = 0; j < N; j++) v[j] = x[j];
2812
+ v[N] = 0;
2813
+ rest.multiply(v, jv);
2814
+ if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
2815
+ lin.apply(v, jv, 1, 1 / dt);
2816
+ const out = new Float64Array(N + K);
2817
+ for (let j = 0; j < N; j++) {
2818
+ let t = jv[j];
2819
+ for (let a = 0; a < K; a++) t += Bk[a][j] * x[N + a];
2820
+ out[j] = t * rowScale[j];
2821
+ }
2822
+ for (let a = 0; a < K; a++) {
2823
+ let t = Dk[a] * x[N + a];
2824
+ for (let j = 0; j < N; j++) t += Ck[a][j] * x[j];
2825
+ out[N + a] = t;
2826
+ }
2827
+ // A pinned row is its constraint's (a conserved amount, an island's summed balance).
2828
+ for (const p of pins) {
2829
+ let t = 0;
2830
+ for (let j = 0; j < p.len; j++) t += p.w[j] * x[p.idx[j]];
2831
+ out[R[p.row]] = t;
2832
+ }
2833
+ return precondition(out);
2834
+ };
2835
+ const rhs = new Float64Array(N + 1), out = new Float64Array(N + 1), outV = new Float64Array(K);
2836
+ const precondition = (r) => {
2837
+ for (let j = 0; j < N; j++) rhs[j] = r[j];
2838
+ fl.forEach((k, a) => (termRes[k] = r[N + a]));
2839
+ for (const p of pins) {
2840
+ p.res = rhs[R[p.row]];
2841
+ rhs[R[p.row]] = 0;
2842
+ }
2843
+ this._solveBordered(rhs, out, outV, pins);
2844
+ const z = new Float64Array(N + K);
2845
+ for (let j = 0; j < N; j++) z[j] = out[j];
2846
+ for (let a = 0; a < K; a++) z[N + a] = outV[a];
2847
+ return z;
2848
+ };
2849
+ try {
2850
+ const b = new Float64Array(N + K);
2851
+ for (let j = 0; j < N; j++) b[j] = save.res[j];
2852
+ fl.forEach((k, a) => (b[N + a] = save.termRes[k]));
2853
+ pins.forEach((p, a) => (b[R[p.row]] = save.pinRes[a]));
2854
+ const pb = precondition(b), x = Float64Array.from(pb);
2855
+ // A lost mode or two, GMRES finds in a couple of iterations. The refined update is kept
2856
+ // where GMRES converged and its correction is under a thermal unit; otherwise newton()
2857
+ // stops refining.
2858
+ const x0 = Float64Array.from(x);
2859
+ const ok = gmresReal(op, pb, x, { m: 8, restarts: 1, tol: 1e-8 });
2860
+ let c = 0;
2861
+ for (let j = 0; j < N + K; j++) c = Math.max(c, Math.abs(x[j] - x0[j]));
2862
+ if (!ok || !(c < 1)) return false;
2863
+ for (let j = 0; j < N; j++) delta[j] = x[j];
2864
+ for (let a = 0; a < K; a++) deltaV[a] = x[N + a];
2865
+ return true;
2866
+ } finally {
2867
+ restoreArrays();
2868
+ }
2869
+ }
2870
+
2350
2871
  // Largest update among the potential-like unknowns (φ̂ and η at grid nodes).
2351
2872
  _maxPotentialStep(delta, deltaV = this.deltaV) { // (deltaV null: the device's potentials only)
2352
2873
  const { n, M } = this;
@@ -2369,16 +2890,55 @@ export class Solver {
2369
2890
  * Newton iteration for one backward-Euler step from the current cOld.
2370
2891
  * @returns {{converged: boolean, iterations: number, history: number[]}}
2371
2892
  */
2372
- newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10 } = {}) {
2893
+ newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands = false } = {}) {
2373
2894
  const { delta, res } = this;
2374
2895
  const deltaV = this.deltaV ?? (this.deltaV = new Float64Array(this.floating.length));
2375
2896
  const history = [], ownHistory = [];
2376
- // A steady solve that fails: record how nearly singular the system was, and where.
2897
+ // Refined solves (see _refine), once the plain ones stall.
2898
+ let refine = false, refined = 0, noRefine = false;
2899
+ // Islands (see _applyIslands): their summed rows go in only once the plain solves converge.
2900
+ // Far from the solution, Newton exact along a weakly held level takes it by 1e5 thermal
2901
+ // units (a floating base, cold at bias), where the plain solves, which barely see that level,
2902
+ // converge. Converged, an island at the wrong level shows: it takes in a different current
2903
+ // than it passes on, so the terminal currents don't add up to zero. Then the summed rows go
2904
+ // in and Newton carries on from there. Converged again, a current that tiny against the
2905
+ // conduction around it rides on differences (~1e-28 thermal units for 5 fA/m² through a bulk
2906
+ // of 1e4 mol/m³ at 1e-4 m²/s) that the last update leaves known only to its own round-off:
2907
+ // where the currents still don't add up, one more update polishes them. Where the islands'
2908
+ // own system is held by static pivots elsewhere (a GaAs stack's minority carriers, at 1e-17
2909
+ // A/m² of noise), their rows can throw Newton off instead: if it doesn't converge again
2910
+ // within a dozen iterations, the plain solution stands.
2911
+ this.islandsOn = islands;
2912
+ let rounds = 0, kept = null;
2913
+ const islandsOff = (it, result) => {
2914
+ if (dt !== Infinity || !this.constrained || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
2915
+ this.islandsOn = true;
2916
+ rounds++;
2917
+ kept = { it, result, u: Float64Array.from(this.u), uLo: Float64Array.from(this.uLo), termV: Float64Array.from(this.termV) };
2918
+ return true;
2919
+ };
2920
+ // A steady solve that fails: record how nearly singular the system was, and where; or, failing
2921
+ // with the islands' rows in, go back to the solution without them.
2377
2922
  const fail = (r) => {
2923
+ if (kept) {
2924
+ this.u.set(kept.u);
2925
+ this.uLo.set(kept.uLo);
2926
+ this.termV.set(kept.termV);
2927
+ this.islandsOn = false;
2928
+ this.computeConcentrations();
2929
+ return { ...kept.result, iterations: r.iterations, history };
2930
+ }
2378
2931
  if (dt === Infinity) this._noteConditioning();
2379
2932
  return r;
2380
2933
  };
2381
2934
  for (let it = 1; it <= maxIter; it++) {
2935
+ if (kept && it - kept.it > 12) return fail({ converged: false, iterations: it - 1, history });
2936
+ if (kept && it === kept.it + 2) {
2937
+ // Refined from the islands' second update (their first moves the level, perhaps by
2938
+ // more than a refinement may), since the plain solves have shown a mode they lose.
2939
+ refine = true;
2940
+ noRefine = false;
2941
+ }
2382
2942
  try {
2383
2943
  this.assemble(dt);
2384
2944
  } catch (err) {
@@ -2395,9 +2955,13 @@ export class Solver {
2395
2955
  return fail({ converged: false, iterations: it, history, error: err.message });
2396
2956
  }
2397
2957
  try {
2398
- const pins = this.constrained && dt === Infinity ? this.constraints : [];
2958
+ const pins = this.pins;
2399
2959
  deltaV.fill(0);
2400
2960
  this._solveBordered(res, delta, deltaV, pins);
2961
+ if (refine && !this._refine(dt, delta, deltaV)) {
2962
+ refine = false; // (GMRES didn't converge, or strayed: the plain solves it is)
2963
+ noRefine = true;
2964
+ }
2401
2965
  if (this.transformed) this._untransform(delta);
2402
2966
  } catch (err) {
2403
2967
  if (!(err instanceof SolverError)) throw err;
@@ -2414,7 +2978,7 @@ export class Solver {
2414
2978
  ownHistory.push(own);
2415
2979
  if (!Number.isFinite(step)) return fail({ converged: false, iterations: it, history, error: 'non-finite update' });
2416
2980
  // Give up early on clear divergence; the caller will take a smaller step instead.
2417
- if (own > 1e4 || (it > 6 && own > 10 * ownHistory[0])) {
2981
+ if ((it > 1 && own > 1e4) || (it > 6 && own > 10 * ownHistory[0])) {
2418
2982
  this.computeConcentrations();
2419
2983
  return fail({ converged: false, iterations: it, history, error: 'diverging' });
2420
2984
  }
@@ -2422,8 +2986,10 @@ export class Solver {
2422
2986
  this._addToState(delta, -alpha);
2423
2987
  this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
2424
2988
  if (alpha === 1 && step < tol) {
2989
+ const done = { converged: true, iterations: it, history, residual: rmax };
2990
+ if (islandsOff(it, done)) continue;
2425
2991
  this.computeConcentrations();
2426
- return { converged: true, iterations: it, history, residual: rmax };
2992
+ return done;
2427
2993
  }
2428
2994
  // Converged as far as round-off allows: the updates are already tiny (below 1e-6 thermal
2429
2995
  // units, ~26 nV) and have stopped shrinking. A badly conditioned system's floor can sit
@@ -2432,15 +2998,35 @@ export class Solver {
2432
2998
  // (Raising this floor lets a weakly held population drift: a MOS capacitor without a
2433
2999
  // channel port then showed a DC leak through its oxide.)
2434
3000
  const [p1, p2] = [history[history.length - 2], history[history.length - 3]], floor = 1e-6;
2435
- if (alpha === 1 && it >= 4 && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3001
+ // Stalled near the solution (not shrinking quadratically), or growing undamped twice
3002
+ // running: the factorised Jacobian may have lost a slow mode to cancellation, and
3003
+ // round-off's floor would accept it wrong, or its noise keep Newton from the solution (a
3004
+ // bipolar stack's floating base, its holes held ~1e14 more weakly than they move within
3005
+ // it). Refine the solves from here.
3006
+ if (refine) refined++;
3007
+ else if (!noRefine && alpha === 1 && it >= 3 && step > 0.25 * p1 && (step < 1e-4 || (step > p1 && p1 > p2))) refine = true;
3008
+ if (alpha === 1 && it >= 4 && (!refine || refined >= 1 || noRefine) && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3009
+ const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3010
+ if (islandsOff(it, done)) continue;
2436
3011
  this.computeConcentrations();
2437
- return { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3012
+ return done;
2438
3013
  }
2439
3014
  }
2440
3015
  this.computeConcentrations();
2441
3016
  return fail({ converged: false, iterations: maxIter, history });
2442
3017
  }
2443
3018
 
3019
+ // How far the terminal currents (as last assembled) are from adding up to zero, relative to the
3020
+ // largest of them: Kirchhoff's law, which a steady state keeps.
3021
+ _kirchhoff() {
3022
+ let sum = 0, mx = 0;
3023
+ for (const I of this.termI) {
3024
+ sum += I;
3025
+ mx = Math.max(mx, Math.abs(I));
3026
+ }
3027
+ return mx > 0 ? Math.abs(sum) / mx : 0;
3028
+ }
3029
+
2444
3030
  // How many digits the last factorisation lost to cancellation, and where (the worst seen since
2445
3031
  // the steady solve began). Only for diagnosing failures: a saturated species, for instance,
2446
3032
  // can lose digits harmlessly.
@@ -2557,6 +3143,20 @@ export class Solver {
2557
3143
  let result;
2558
3144
  try {
2559
3145
  result = this.newton(dtEff, opts);
3146
+ if (!result.converged && dtEff === Infinity && this.constrained && this.islands.length > 0) {
3147
+ // Plain solves that don't converge may be lost along an island's level (two regions
3148
+ // conducting 1e20 times less hold it): once more from the start, with the islands'
3149
+ // summed rows in throughout (see newton).
3150
+ this.u.set(this.uPrev);
3151
+ this.uLo.set(this.uPrevLo);
3152
+ this.termV.set(this.termVPrev);
3153
+ if (opts.guess) {
3154
+ this.u.set(opts.guess);
3155
+ this.uLo.fill(0);
3156
+ }
3157
+ const again = this.newton(dtEff, { ...opts, islands: true });
3158
+ result = { ...again, iterations: result.iterations + again.iterations };
3159
+ }
2560
3160
  } finally {
2561
3161
  this.combining = false;
2562
3162
  }
@@ -2583,7 +3183,7 @@ export class Solver {
2583
3183
  /**
2584
3184
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
2585
3185
  * estimated against an explicit predictor through the previous states, and controlled to
2586
- * `tol` thermal units per step in every potential (φ̂, each η, a floating terminal). The first
3186
+ * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
2587
3187
  * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
2588
3188
  * animation can call it once per frame; the step size carries over between calls.
2589
3189
  */
@@ -2599,11 +3199,11 @@ export class Solver {
2599
3199
  if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
2600
3200
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2601
3201
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2602
- let grow = 0; // longer first steps tried after a Newton failure (see below)
2603
3202
  // The shortest step worth trying: round-off relative to the time, or the device's fastest
2604
3203
  // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
2605
3204
  const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
2606
3205
  const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
3206
+ const firstErrs = []; // the first step's error at each size tried (see below)
2607
3207
  const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
2608
3208
  while (this.time < tEnd) {
2609
3209
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
@@ -2628,6 +3228,7 @@ export class Solver {
2628
3228
  this.landing = atBreak ? tb : clamped ? tEnd : undefined;
2629
3229
  const snap = this._snapshot();
2630
3230
  let err, p, half = null;
3231
+ if (this.history.length > 0) firstErrs.length = 0;
2631
3232
  if (this.history.length === 0) {
2632
3233
  // First step: backward Euler, checked against two half steps (whose result is kept).
2633
3234
  const full = this.step(h);
@@ -2648,22 +3249,13 @@ export class Solver {
2648
3249
  if (err === undefined) {
2649
3250
  this._restore(snap);
2650
3251
  rejected++;
2651
- // Newton failed on the first step after a start or a jump. Usually a shorter step
2652
- // helps; but right after a jump in a strictly neutral (ε = 0) material, the shorter the
2653
- // step the worse conditioned it is (the field is fixed only through fluxes, which the
2654
- // storage term dwarfs), so try longer ones first.
2655
- if (this.strictlyNeutral && grow < 3 && h * 16 < tEnd - this.time) {
2656
- grow++;
2657
- dt = h * 16;
2658
- } else {
2659
- grow = 3;
2660
- dt = h / 4;
2661
- }
3252
+ dt = h / 4;
2662
3253
  if (dt < floor) { failed = true; break; }
2663
3254
  continue;
2664
3255
  }
2665
3256
  } else {
2666
- const r = this.step(h, { method, guess: this._extrapolate(guess, this.time + h) });
3257
+ // (Order 2 once three consistent states can check it.)
3258
+ const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, this.time + h) });
2667
3259
  iterations += r.iterations;
2668
3260
  if (!r.converged) {
2669
3261
  rejected++;
@@ -2675,14 +3267,25 @@ export class Solver {
2675
3267
  err = this._errorNorm(pred) * p.scale;
2676
3268
  p = p.order;
2677
3269
  }
2678
- if (err > tol) {
3270
+ // A first step whose error doesn't shrink with it: a jump in a level held at a boundary
3271
+ // (a bath's, after a voltage step) starts a profile self-similar in x/√t, so a first step
3272
+ // of any length errs alike, down to the grid's own diffusion time, below Newton's reach.
3273
+ // Three tries in a row, each within a factor 2 of the last, say so: the error is the
3274
+ // jump's, not the step's. Take the step (backward Euler damps it as it should) and grow.
3275
+ if (half && err > tol) firstErrs.push(err);
3276
+ const jump = half && firstErrs.length >= 3 && firstErrs.slice(-3).every((e, j, a) => j === 0 || (e > 0.5 * a[j - 1] && e < 2 * a[j - 1]));
3277
+ if (err > tol && !jump) {
2679
3278
  this._restore(snap);
2680
3279
  rejected++;
2681
3280
  dt = h * factor(err, p);
2682
3281
  continue;
2683
3282
  }
2684
3283
  steps++;
2685
- grow = 0;
3284
+ // The state the first step started from needn't satisfy the algebraic equations (φ in a
3285
+ // strictly neutral region, an interface's unknowns, after a start or a jump), and they
3286
+ // jump in the first instant; a predictor through it would see that jump as error on every
3287
+ // step after. The history starts from the half step.
3288
+ if (half) this.history.length = 1;
2686
3289
  dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
2687
3290
  if (atBreak) this.history = []; // the solution's slope jumps here: restart the order
2688
3291
  if (half) {
@@ -2809,6 +3412,8 @@ export class Solver {
2809
3412
  for (let i = 0; i < n; i++) {
2810
3413
  if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
2811
3414
  }
3415
+ const k = this.surfPort[g]; // an electrode surface's coverages, as η
3416
+ if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(u[b * M + 1 + n + q] - ref[b * M + 1 + n + q]));
2812
3417
  }
2813
3418
  return mx;
2814
3419
  }
@@ -2935,52 +3540,17 @@ export class Solver {
2935
3540
  for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
2936
3541
  const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
2937
3542
  const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
2938
- // J·v + (∂res/∂V_k)σ from the residual itself, by a central difference: the assembled J holds
2939
- // a flux's dependence on η_L and η_R as two entries, and where both are huge (an inversion
2940
- // layer) and v nearly uniform, J·v loses the flux to round-off; the residual takes the η
2941
- // difference first, in double-double. So η moves in the low word, φ̂ and the flux unknowns
2942
- // in the high one (φ̂ only scales expm1(Δη), near zero there).
2943
- const u0 = Float64Array.from(this.u), uLo0 = Float64Array.from(this.uLo), V0 = Float64Array.from(this.termV);
2944
- // The unknowns perturbed in the low word: a node's η (not φ̂, a metal's segment flux, or a
2945
- // face's unknowns, which are read from the high word).
2946
- const low = new Uint8Array(this.fullOf.length);
2947
- const nodeBlock = new Uint8Array(nB);
2948
- for (let g = 0; g < this.nNodes; g++) if (this.nodeConductor[g] < 0) nodeBlock[this.blockOfNode[g]] = 1;
2949
- for (let j = 0; j < low.length; j++) {
2950
- const full = this.fullOf[j];
2951
- low[j] = nodeBlock[Math.floor(full / M)] && full % M !== 0 ? 1 : 0;
2952
- }
2953
- const resP = new Float64Array(this.res.length), resM = new Float64Array(this.res.length);
2954
- const derivative = (v, k, sigma, out) => {
2955
- let mx = Math.abs(sigma);
2956
- for (let j = 0; j < v.length; j++) mx = Math.max(mx, Math.abs(v[j]));
2957
- const h = 1e-4 / (mx || 1);
2958
- for (const [sgn, into] of [[1, resP], [-1, resM]]) {
2959
- this.u.set(u0);
2960
- this.uLo.set(uLo0);
2961
- this.termV.set(V0);
2962
- for (let j = 0; j < v.length; j++) {
2963
- const full = this.fullOf[j];
2964
- if (low[j]) this.uLo[full] += sgn * h * v[j];
2965
- else this.u[full] += sgn * h * v[j];
2966
- }
2967
- // A held terminal's voltage comes from its source at each assembly; a floating one's is
2968
- // an unknown.
2969
- if (k >= 0 && fl.includes(k)) this.termV[k] += sgn * h * sigma;
2970
- else if (k >= 0) this.sourceOverride.set(k, V0[k] + sgn * h * sigma);
2971
- this.assemble(Infinity);
2972
- if (k >= 0) this.sourceOverride.delete(k);
2973
- into.set(this.res);
2974
- }
2975
- for (let j = 0; j < out.length; j++) out[j] = (resP[j] - resM[j]) / (2 * h);
2976
- };
2977
- const restore = () => {
2978
- this.u.set(u0);
2979
- this.uLo.set(uLo0);
2980
- this.termV.set(V0);
2981
- this._refreshSources();
2982
- this.computeConcentrations();
2983
- };
3543
+ const cuts = this._currentCuts();
3544
+ // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
3545
+ // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
3546
+ // flux to round-off. So the dilute kernels' terms are kept in difference form (see
3547
+ // DifferenceTerms), with the rest from a matrix of their own, steady (Jr) and storage (Sr).
3548
+ this._assembleDifference(Infinity);
3549
+ const Jr = { A: this.sysRest.A.slice(), B: this.sysRest.B.slice(), C: this.sysRest.C.slice() };
3550
+ const { lin } = this._assembleDifference(dts);
3551
+ const Sr = {};
3552
+ for (const X of ['A', 'B', 'C']) Sr[X] = this.sysRest[X].map((v, k) => (v - Jr[X][k]) * dts);
3553
+ this.assemble(Infinity);
2984
3554
 
2985
3555
  const { sizes, offA, offB, offC, offX } = sys;
2986
3556
  const csys = new ComplexBlockTridiagonal(nB, sizes);
@@ -3026,9 +3596,6 @@ export class Solver {
3026
3596
  }
3027
3597
  }
3028
3598
  csys.factor();
3029
- // Each solve uses that residual-based J·v (S·v is safe from the matrix): in an inversion
3030
- // layer fed by minority carriers that number ~1e3 cm⁻³ in the bulk, the assembled J alone
3031
- // gives the layer an exchange path many orders too fast.
3032
3599
  const sx = (X, vr, out) => {
3033
3600
  for (let blk = 0; blk < nB; blk++) {
3034
3601
  const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
@@ -3054,25 +3621,35 @@ export class Solver {
3054
3621
  csys.solve(rRes, iRes, outR, outI);
3055
3622
  return [outR, outI];
3056
3623
  };
3057
- const op = ([vr, vi]) => {
3058
- derivative(vr, -1, 0, jr);
3059
- derivative(vi, -1, 0, ji);
3060
- sx(S, vi, si);
3061
- sx(S, vr, sr);
3062
- const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3624
+ // (J + iωS)·v, exactly (see DifferenceTerms), into (ar, ai).
3625
+ const jv = (vr, vi, ar, ai) => {
3626
+ sx(Jr, vr, jr);
3627
+ sx(Jr, vi, ji);
3628
+ sx(Sr, vi, si);
3629
+ sx(Sr, vr, sr);
3063
3630
  for (let j = 0; j < Nc; j++) {
3064
3631
  ar[j] = jr[j] - w * si[j];
3065
3632
  ai[j] = ji[j] + w * sr[j];
3066
3633
  }
3634
+ lin.apply(vr, ar, 1, 0);
3635
+ lin.apply(vi, ar, 0, -w);
3636
+ lin.apply(vi, ai, 1, 0);
3637
+ lin.apply(vr, ai, 0, w);
3638
+ };
3639
+ const op = ([vr, vi]) => {
3640
+ const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3641
+ jv(vr, vi, ar, ai);
3067
3642
  return precondition(ar, ai);
3068
3643
  };
3069
3644
  const solve = (br, bi, outr, outi, k, sigma) => {
3070
3645
  const rhs = k >= 0 ? precondition(Bt[k].map((v) => -sigma * v), zero) : [new Float64Array(Nc), new Float64Array(Nc)];
3071
3646
  outr.set(rhs[0]);
3072
3647
  outi.set(rhs[1]);
3073
- gmres(op, rhs, [outr, outi]);
3074
- restore();
3648
+ // (GMRES keeps its best iterate, which is never worse than the factorised solve.)
3649
+ const { residual } = gmres(op, rhs, [outr, outi]);
3650
+ accuracy = Math.max(accuracy, residual);
3075
3651
  };
3652
+ let accuracy = 1e-14; // (relative, of the solves: the readouts' error is weighed with it)
3076
3653
  // y: the response with the floating terminals held; X_k: to a unit δV_k.
3077
3654
  const heldT = dT.kind === 'V';
3078
3655
  for (let j = 0; j < Nc; j++) {
@@ -3112,12 +3689,93 @@ export class Solver {
3112
3689
  xr[this.fullOf[j]] = re;
3113
3690
  xi[this.fullOf[j]] = im;
3114
3691
  }
3692
+ // The current into terminal kT per volt, (C + iωC′)·δx + (∂I/∂V)(1 + iω′), with its error.
3693
+ // At the contact it's a sum whose terms can be far larger than itself (in a junction's
3694
+ // neutral ends, a conductance times a δη that cancels its neighbours' to 1e-17), and its
3695
+ // round-off reads as a constant conductance. Between the two contacts of a device without
3696
+ // ports, the total current (conduction and displacement) is the same through every cut, so
3697
+ // it's also read across each segment, and the reading with the least error taken. Each
3698
+ // reading's error is its terms' round-off and the solves' error in δx (to `accuracy` of its
3699
+ // largest entry) through its coefficients: a contact's or a cut's flux is a difference of
3700
+ // levels that the solved rows don't pin.
3701
+ // `parts`: the response as a sum, each part compact (r, i) and full-index (fr, fi), with the
3702
+ // absolute error of its entries.
3703
+ const read = (parts) => {
3704
+ const eps = 1.1e-16, c = Ct[kT], cs = Cs[kT];
3705
+ let Ir = DI[kT], Ii = w * DIs[kT], err = eps * (Math.abs(DI[kT]) + w * Math.abs(DIs[kT]));
3706
+ for (const p of parts) {
3707
+ const [re, im] = dot(kT, w, p.r, p.i);
3708
+ Ir += re;
3709
+ Ii += im;
3710
+ for (let j = 0; j < Nc; j++) err += (Math.abs(c[j]) + w * Math.abs(cs[j])) * (eps * Math.hypot(p.r[j], p.i[j]) + p.floor);
3711
+ }
3712
+ if (!cuts || kT >= 2) return { Ir, Ii, err };
3713
+ const sgn = kT === 0 ? 1 : -1;
3714
+ for (const cut of cuts) {
3715
+ let re = 0, im = 0, e = 0;
3716
+ for (const p of parts) {
3717
+ for (let a = 0; a < cut.idx.length; a++) {
3718
+ const x = cut.idx[a], gk = cut.g[a], dk = w * cut.d[a];
3719
+ // (g + iωd)(xr + i xi)
3720
+ re += gk * p.fr[x] - dk * p.fi[x];
3721
+ im += gk * p.fi[x] + dk * p.fr[x];
3722
+ e += (Math.abs(gk) + Math.abs(dk)) * (eps * Math.hypot(p.fr[x], p.fi[x]) + p.floor);
3723
+ }
3724
+ }
3725
+ if (e < err) [err, Ir, Ii] = [e, sgn * re, sgn * im];
3726
+ }
3727
+ return { Ir, Ii, err };
3728
+ };
3729
+ const largest = (r, i) => {
3730
+ let m = 0;
3731
+ for (let j = 0; j < r.length; j++) m = Math.max(m, Math.hypot(r[j], i[j]));
3732
+ return m;
3733
+ };
3734
+ const full = (r, i) => {
3735
+ const fr = new Float64Array(N), fi = new Float64Array(N);
3736
+ for (let j = 0; j < Nc; j++) {
3737
+ fr[this.fullOf[j]] = r[j];
3738
+ fi[this.fullOf[j]] = i[j];
3739
+ }
3740
+ return [fr, fi];
3741
+ };
3115
3742
  let Zr, Zi;
3116
3743
  if (heldT) {
3117
- // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal
3118
- let [Ir, Ii] = dot(kT, w, yr, yi);
3119
- Ir += DI[kT];
3120
- Ii += w * DIs[kT];
3744
+ // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal…
3745
+ let { Ir, Ii, err } = read([{ r: yr, i: yi, fr: xr, fi: xi, floor: accuracy * largest(yr, yi) }]);
3746
+ // …and where the solves leave that unresolved (the estimate past 1% of the current), the
3747
+ // response is split: a uniform shift of each region (z_i s on every η_i, s on φ̂), which
3748
+ // changes no flux and no concentration, so that J·e vanishes inside each region and is
3749
+ // taken at its edges; and what's left, small, solved for to GMRES's floor, its error now
3750
+ // relative to itself. An electrolyte at its open circuit needs it: the response to its
3751
+ // terminal is nearly such a shift, and its current the slope of levels uniform to 1e-14,
3752
+ // more than a double can hold. (Not with ports, whose exchange sees the shift.)
3753
+ if (K === 0 && cuts && err > 1e-2 * Math.hypot(Ir, Ii)) {
3754
+ const [er, ei] = this._regionShift(yr, yi);
3755
+ const tr = new Float64Array(Nc), ti = new Float64Array(Nc);
3756
+ jv(er, ei, tr, ti);
3757
+ const edge = this._regionEdgeRows();
3758
+ const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3759
+ for (let j = 0; j < Nc; j++) {
3760
+ ar[j] = -Bt[kT][j] - (edge[j] ? tr[j] : 0);
3761
+ ai[j] = edge[j] ? -ti[j] : 0;
3762
+ }
3763
+ const rhs = precondition(ar, ai);
3764
+ const dr = yr.map((v, j) => v - er[j]), di = yi.map((v, j) => v - ei[j]);
3765
+ const { residual } = gmres(op, rhs, [dr, di], { tol: 1e-11 });
3766
+ const [efr, efi] = full(er, ei), [dfr, dfi] = full(dr, di);
3767
+ const r2 = read([
3768
+ { r: er, i: ei, fr: efr, fi: efi, floor: 0 },
3769
+ { r: dr, i: di, fr: dfr, fi: dfi, floor: Math.max(1e-14, residual) * largest(dr, di) },
3770
+ ]);
3771
+ if (r2.err < err) ({ Ir, Ii, err } = r2);
3772
+ if (profiles) {
3773
+ for (let j = 0; j < N; j++) {
3774
+ xr[j] = efr[j] + dfr[j];
3775
+ xi[j] = efi[j] + dfi[j];
3776
+ }
3777
+ }
3778
+ }
3121
3779
  const d2 = Ir * Ir + Ii * Ii; // Z = 1/δI
3122
3780
  Zr = Ir / d2;
3123
3781
  Zi = -Ii / d2;
@@ -3134,6 +3792,125 @@ export class Solver {
3134
3792
  return out;
3135
3793
  }
3136
3794
 
3795
+ // A uniform shift of each region, fitted to a small-signal response (compact, complex): φ̂ by
3796
+ // s_r and each species' η by z_i s_r, with s_r the region's mean δφ̂ (or, where φ̂ isn't an
3797
+ // unknown, its carriers' mean δη/z). Coverages and flux unknowns aren't shifted.
3798
+ _regionShift(yr, yi) {
3799
+ const { n, M, z, loc, model } = this, R = this.rix, { grid } = model;
3800
+ const er = new Float64Array(yr.length), ei = new Float64Array(yr.length);
3801
+ for (let r = 0; r < model.regions.length; r++) {
3802
+ let sr = 0, si = 0, k = 0;
3803
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
3804
+ for (let g = g0; g <= g1; g++) {
3805
+ const o = this.blockOfNode[g] * M;
3806
+ if (loc[o] >= 0) {
3807
+ sr += yr[R[o]];
3808
+ si += yi[R[o]];
3809
+ k++;
3810
+ }
3811
+ }
3812
+ if (k === 0) {
3813
+ for (let g = g0; g <= g1; g++) {
3814
+ const o = this.blockOfNode[g] * M;
3815
+ for (let i = 0; i < n; i++) {
3816
+ if (z[i] === 0 || loc[o + 1 + i] < 0) continue;
3817
+ sr += yr[R[o + 1 + i]] / z[i];
3818
+ si += yi[R[o + 1 + i]] / z[i];
3819
+ k++;
3820
+ }
3821
+ }
3822
+ }
3823
+ if (k === 0) continue;
3824
+ sr /= k;
3825
+ si /= k;
3826
+ for (let g = g0; g <= g1; g++) {
3827
+ const o = this.blockOfNode[g] * M;
3828
+ if (loc[o] >= 0) {
3829
+ er[R[o]] = sr;
3830
+ ei[R[o]] = si;
3831
+ }
3832
+ for (let i = 0; i < n; i++) {
3833
+ if (loc[o + 1 + i] < 0) continue;
3834
+ er[R[o + 1 + i]] = z[i] * sr;
3835
+ ei[R[o + 1 + i]] = z[i] * si;
3836
+ }
3837
+ }
3838
+ }
3839
+ return [er, ei];
3840
+ }
3841
+
3842
+ // Compact rows a region's uniform shift can touch: faces, the nodes beside them, and the two
3843
+ // ends (whatever holds a level from outside). Inside a region, every row is unchanged by it.
3844
+ _regionEdgeRows() {
3845
+ const { M, loc, nB } = this, R = this.rix;
3846
+ const edge = new Uint8Array(this.sys.size);
3847
+ const mark = (b) => {
3848
+ if (b < 0 || b >= nB) return;
3849
+ for (let r = 0; r < M; r++) if (loc[b * M + r] >= 0) edge[R[b * M + r]] = 1;
3850
+ };
3851
+ mark(0);
3852
+ mark(nB - 1);
3853
+ for (let f = 0; f < this.nFaces; f++) {
3854
+ const b = this.blockOfFace[f];
3855
+ mark(b - 1);
3856
+ mark(b);
3857
+ mark(b + 1);
3858
+ }
3859
+ return edge;
3860
+ }
3861
+
3862
+ // The cuts where the impedance may read a small-signal current (null where the current
3863
+ // differs between cuts: a device with ports). Each is the linearised total current toward +x
3864
+ // through one segment of an ideal region, Σ (g + iω d)·δu over the unknowns idx: the
3865
+ // Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the displacement's.
3866
+ _currentCuts() {
3867
+ const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
3868
+ if (model.ports.length > 0) return null;
3869
+ const { grid, materials, regions } = model;
3870
+ const cuts = [];
3871
+ for (let r = 0; r < regions.length; r++) {
3872
+ const reg = regions[r], mat = materials[reg.material];
3873
+ if (mat.conductor || !mat.ideal || reg.mixing > 0) continue;
3874
+ const vel = reg.velocity;
3875
+ for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
3876
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
3877
+ const terms = new Map();
3878
+ const add = (x, g, d) => {
3879
+ const t = terms.get(x) || [0, 0];
3880
+ t[0] += g;
3881
+ t[1] += d;
3882
+ terms.set(x, t);
3883
+ };
3884
+ const phiL = u[bL * M], phiR = u[bR * M], hasPhi = !this.phiUndefined[s];
3885
+ for (let i = 0; i < n; i++) {
3886
+ if (z[i] === 0 || !mat.present[i] || mat.D[i] === 0) continue;
3887
+ const zi = z[i], r1 = 1 + i, cL = c[s * n + i], g = mat.D[i] / h;
3888
+ const pe = (vel * h) / mat.D[i];
3889
+ const d = zi * (phiR - phiL) - pe;
3890
+ const E = Math.expm1(u[bR * M + r1] - u[bL * M + r1] + (uLo[bR * M + r1] - uLo[bL * M + r1]) - pe);
3891
+ const gBc = g * bernoulli(d) * cL;
3892
+ const dNdd = -g * bernoulliDerivative(d) * cL * E;
3893
+ add(bL * M + r1, zi * F * gBc, 0);
3894
+ add(bR * M + r1, -zi * F * gBc * (E + 1), 0);
3895
+ if (hasPhi) {
3896
+ add(bL * M, zi * F * (-zi * dNdd + zi * gBc * E), 0);
3897
+ add(bR * M, zi * F * zi * dNdd, 0);
3898
+ }
3899
+ }
3900
+ // The displacement toward +x, ε(φ_L − φ_R)/h (in φ̂ = φ/V_T).
3901
+ const k = hasPhi ? (mat.epsr * EPS0 * this.VT) / h : 0;
3902
+ if (k > 0) {
3903
+ add(bL * M, 0, k);
3904
+ add(bR * M, 0, -k);
3905
+ }
3906
+ if (terms.size === 0) continue;
3907
+ const idx = [...terms.keys()];
3908
+ cuts.push({ idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
3909
+ }
3910
+ }
3911
+ return cuts;
3912
+ }
3913
+
3137
3914
  // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
3138
3915
  _smallSignalProfiles(xr, xi) {
3139
3916
  const { n, M, z, VT, model } = this;
@@ -3263,6 +4040,28 @@ export class Solver {
3263
4040
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
3264
4041
  // a change of drive).
3265
4042
  solveSteady(opts = {}) {
4043
+ this.flattening = true;
4044
+ this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4045
+ try {
4046
+ return this._solveSteadyAll(opts);
4047
+ } finally {
4048
+ this.flattening = false;
4049
+ }
4050
+ }
4051
+
4052
+ _solveSteadyAll(opts) {
4053
+ // A terminal that passes current only by charging (a gate, a capacitance with no species
4054
+ // through it) has no steady state under a current drive: it charges for ever, or at I = 0
4055
+ // keeps whatever charge it started with, which a steady solve doesn't know.
4056
+ const { species } = this.model, ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
4057
+ for (const k of this.floating) {
4058
+ const t = this.terms[k];
4059
+ if (t.drive.kind !== 'I') continue;
4060
+ const only = t.kind === 'port' ? (p) => p.reactions.length === 0 && !ions(p.species) : (c) => !ions(c.species);
4061
+ if (only(t.kind === 'port' ? this.model.ports[t.index] : this.model.contacts[t.side])) {
4062
+ throw new DeviceError(`${t.kind === 'port' ? `ports[${t.index}]` : `contacts.${t.side}`} passes current only by charging, so driven by a current it has no steady state; hold it at V (or a source behind R), or advance() in time`);
4063
+ }
4064
+ }
3266
4065
  const r = this._steadyFromHere(opts);
3267
4066
  this.atSteady = r.converged;
3268
4067
  return r;
@@ -3288,25 +4087,44 @@ export class Solver {
3288
4087
  if (r.converged) return r;
3289
4088
  // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
3290
4089
  // nothing, each solve warm from the last.
4090
+ let dimmer = false;
3291
4091
  if (this.hasGeneration && opts.continuation !== false) {
3292
4092
  this.u.set(u0);
3293
4093
  this.uLo.set(u0Lo);
3294
4094
  this.termV.set(v0);
3295
4095
  this.computeConcentrations();
4096
+ this.dimmer = false;
3296
4097
  const g = this._generationContinuation(opts, r);
4098
+ dimmer = this.dimmer;
3297
4099
  if (g.converged) {
3298
4100
  this.solvedV = target;
3299
4101
  return g;
3300
4102
  }
3301
4103
  r = g;
3302
4104
  }
4105
+ // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
4106
+ // voltage until the current crosses its target, and float it from there.
4107
+ if (opts.continuation !== false && this.floating.some((k) => this.terms[k].drive.kind === 'I')) {
4108
+ this.u.set(u0);
4109
+ this.uLo.set(u0Lo);
4110
+ this.termV.set(v0);
4111
+ this.computeConcentrations();
4112
+ const q = this._floatingContinuation(opts, r);
4113
+ if (q.converged) return q;
4114
+ r = q;
4115
+ }
3303
4116
  if (!canContinue) {
3304
4117
  if (quick) {
3305
- this.u.set(u0);
3306
- this.uLo.set(u0Lo);
3307
- this.termV.set(v0);
4118
+ // The full pseudo-transient ramp: from the dimmer light's solution where the light's
4119
+ // continuation got anywhere (a lit floating base charges from there as it would in
4120
+ // time), else from the start.
4121
+ if (!dimmer) {
4122
+ this.u.set(u0);
4123
+ this.uLo.set(u0Lo);
4124
+ this.termV.set(v0);
4125
+ }
3308
4126
  this.computeConcentrations();
3309
- const full = this._solveSteady(opts); // the full pseudo-transient ramp
4127
+ const full = this._solveSteady(opts);
3310
4128
  return { ...full, steps: full.steps + r.steps, iterations: full.iterations + r.iterations };
3311
4129
  }
3312
4130
  return r;
@@ -3334,18 +4152,121 @@ export class Solver {
3334
4152
  return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
3335
4153
  }
3336
4154
 
4155
+ // A cold steady solve with a terminal driven by a current, which can fail where the same state
4156
+ // is easy warm (a solar cell's open circuit, from cold on a fine grid): the terminal held at a
4157
+ // voltage instead, whose steady solves have their own continuation, the voltage marched from
4158
+ // the start's until the current crosses its target, the crossing narrowed by bisection, and
4159
+ // the terminal floated again from beside it.
4160
+ _floatingContinuation(opts, r) {
4161
+ const k = this.floating.find((j) => this.terms[j].drive.kind === 'I');
4162
+ const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
4163
+ let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
4164
+ const history = (r.history ?? []).slice();
4165
+ const hold = (V) => {
4166
+ this.terms[k].drive = { kind: 'V', src: { value: V }, R: 0 };
4167
+ this.redrive();
4168
+ const q = this._steadyFromHere(opts);
4169
+ steps += q.steps ?? 0;
4170
+ iterations += q.iterations ?? 0;
4171
+ history.push({ held: V, converged: q.converged });
4172
+ if (q.converged) seen.push([V, this.termI[k]]);
4173
+ return q.converged ? this.termI[k] - target : NaN;
4174
+ };
4175
+ const fail = () => ({ converged: false, steps, iterations, history });
4176
+ const seen = []; // [V, I] of each held solve that converged
4177
+ let Va = this.termV[k], fa, Vb, fb;
4178
+ try {
4179
+ fa = hold(Va);
4180
+ if (!Number.isFinite(fa)) {
4181
+ Va = 0;
4182
+ fa = hold(Va);
4183
+ }
4184
+ if (!Number.isFinite(fa)) return fail();
4185
+ // March, the step growing: up where the current is below its target, since raising a
4186
+ // terminal's voltage raises the current into a passive device (the other way, from the
4187
+ // start again, if 20 V brings no crossing).
4188
+ const start = Va, f0 = fa;
4189
+ [Vb, fb] = [Va, fa];
4190
+ for (const dir of [fa < 0 ? 1 : -1, fa < 0 ? -1 : 1]) {
4191
+ [Va, fa] = [start, f0];
4192
+ if (dir !== (f0 < 0 ? 1 : -1)) hold(start); // (back to the start, for a warm march)
4193
+ let dV = 0.02;
4194
+ Vb = Va + dir * dV;
4195
+ fb = hold(Vb);
4196
+ while (Number.isFinite(fb) && Math.sign(fb) === Math.sign(fa) && fa !== 0 && Math.abs(Vb - start) < 20) {
4197
+ [Va, fa] = [Vb, fb];
4198
+ dV *= 1.5;
4199
+ Vb = Va + dir * dV;
4200
+ fb = hold(Vb);
4201
+ }
4202
+ if (Number.isFinite(fb) && (Math.sign(fb) !== Math.sign(fa) || fa === 0)) break;
4203
+ }
4204
+ if (!Number.isFinite(fb) || (Math.sign(fb) === Math.sign(fa) && fa !== 0)) {
4205
+ // What the held voltages passed, at the two extremes reached.
4206
+ if (seen.length > 1) {
4207
+ seen.sort((a, b) => a[0] - b[0]);
4208
+ this.unreached = { terminal: this.terms[k].name, target, low: seen[0], high: seen.at(-1) };
4209
+ }
4210
+ return fail();
4211
+ }
4212
+ // Bisect (each held solve warm from the last) to a tenth of a millivolt.
4213
+ for (let it = 0; it < 40 && Math.abs(Vb - Va) > 1e-4; it++) {
4214
+ const Vm = (Va + Vb) / 2, fm = hold(Vm);
4215
+ if (!Number.isFinite(fm)) return fail();
4216
+ if (Math.sign(fm) === Math.sign(fa)) [Va, fa] = [Vm, fm];
4217
+ else [Vb, fb] = [Vm, fm];
4218
+ }
4219
+ hold(Math.abs(fa) < Math.abs(fb) ? Va : Vb);
4220
+ } finally {
4221
+ this.terms[k].drive = drive;
4222
+ this.redrive();
4223
+ }
4224
+ const q = this._solveSteady(opts);
4225
+ steps += q.steps ?? 0;
4226
+ iterations += q.iterations ?? 0;
4227
+ history.push(...(q.history ?? []));
4228
+ if (q.converged) return { ...q, steps, iterations, history };
4229
+ // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
4230
+ // limit: its bordered row 33 digits short), but a current-driven steady state is the held
4231
+ // one at the voltage that passes the target: found by held solves, by regula falsi
4232
+ // (Illinois) within the bracket, to 1e-10 of the target.
4233
+ try {
4234
+ let side = 0;
4235
+ for (let it = 0; it < 60; it++) {
4236
+ const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
4237
+ if (!Number.isFinite(fm)) return fail();
4238
+ if (Math.abs(fm) <= 1e-10 * Math.abs(target)) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4239
+ if (Math.sign(fm) === Math.sign(fb)) {
4240
+ [Vb, fb] = [Vm, fm];
4241
+ if (side === -1) fa /= 2;
4242
+ side = -1;
4243
+ } else {
4244
+ [Va, fa] = [Vm, fm];
4245
+ if (side === 1) fb /= 2;
4246
+ side = 1;
4247
+ }
4248
+ }
4249
+ return fail();
4250
+ } finally {
4251
+ this.terms[k].drive = drive;
4252
+ this.redrive();
4253
+ }
4254
+ }
4255
+
3337
4256
  // Steady solves with the generation reactions' rates scaled from 1e-12 up to 1, ×100 a step
3338
4257
  // while they converge, smaller steps where they don't.
3339
4258
  _generationContinuation(opts, r) {
3340
4259
  const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
3341
- let s = 1e-12, factor = 100, steps = r.steps, iterations = r.iterations;
4260
+ let s = 1e-30, factor = 100, steps = r.steps, iterations = r.iterations;
3342
4261
  const history = r.history.slice();
3343
4262
  try {
3344
4263
  this.generationScale = s;
4264
+ // (nearly dark: a cold start can need the full ramp, as a dark device's does)
3345
4265
  let q = this._solveSteady(sub);
3346
4266
  steps += q.steps;
3347
4267
  iterations += q.iterations;
3348
4268
  if (!q.converged) return { converged: false, steps, iterations, history };
4269
+ this.dimmer = true; // (the state is now a solution under dimmer light: a better start than cold)
3349
4270
  while (s < 1) {
3350
4271
  const next = Math.min(1, s * factor);
3351
4272
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
@@ -3363,7 +4284,7 @@ export class Solver {
3363
4284
  this.termV.set(v1);
3364
4285
  this.computeConcentrations();
3365
4286
  factor = Math.sqrt(factor);
3366
- if (factor < 1.01) return { converged: false, steps, iterations, history };
4287
+ if (factor < 1.5) return { converged: false, steps, iterations, history }; // (crawling: time steps do better)
3367
4288
  }
3368
4289
  }
3369
4290
  return { converged: true, steps, iterations, history };
@@ -3381,6 +4302,17 @@ export class Solver {
3381
4302
  let q;
3382
4303
  if (!warm) {
3383
4304
  q = this._solveSteady(opts);
4305
+ if (!q.converged) {
4306
+ // A cold device's start was laid out for the target (a set() before the first solve
4307
+ // builds it there): lay it out again, level.
4308
+ steps += q.steps;
4309
+ iterations += q.iterations;
4310
+ this._refreshSources();
4311
+ this.initFromComposition();
4312
+ q = this._solveSteady(opts);
4313
+ }
4314
+ // (lit, the level start may need the light ramped up too)
4315
+ if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
3384
4316
  steps += q.steps;
3385
4317
  iterations += q.iterations;
3386
4318
  if (!q.converged) return { ...r, steps, iterations };
@@ -3429,7 +4361,8 @@ export class Solver {
3429
4361
  const direct = this._directSteady();
3430
4362
  const giant = direct ? Infinity : 1e6 * tau;
3431
4363
  if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
3432
- let dt = giant;
4364
+ let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
4365
+ let triedDirect = 0;
3433
4366
  let totalIter = 0, steps = 0, converged = false;
3434
4367
  const history = [];
3435
4368
  while (steps < maxSteps) {
@@ -3450,6 +4383,7 @@ export class Solver {
3450
4383
  // Pseudo-transient continuation: down from a slow time scale until a step converges,
3451
4384
  // as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
3452
4385
  dt = dt >= giant ? tau * 1e-6 : dt / 4;
4386
+ grow = 2;
3453
4387
  if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
3454
4388
  continue;
3455
4389
  }
@@ -3457,6 +4391,21 @@ export class Solver {
3457
4391
  converged = true; // the steady equations themselves were solved
3458
4392
  break;
3459
4393
  }
4394
+ // Past the slowest diffusion time, the direct solve may already reach from here: try it
4395
+ // once a decade (the state kept if it fails), rather than step on to the giant dt through
4396
+ // steps that can fail for slow ions' sake.
4397
+ if (direct && dt < giant && dt >= tau && dt >= 10 * triedDirect) {
4398
+ triedDirect = dt;
4399
+ const snap = this._snapshot();
4400
+ const d = this.step(Infinity);
4401
+ totalIter += d.iterations;
4402
+ history.push({ dt: Infinity, converged: d.converged, iterations: d.iterations });
4403
+ if (d.converged) {
4404
+ converged = true;
4405
+ break;
4406
+ }
4407
+ this._restore(snap);
4408
+ }
3460
4409
  if (dt >= giant && this._maxPotentialStep(this._diff()) < tol) {
3461
4410
  converged = true;
3462
4411
  if (dt > giant) {
@@ -3469,7 +4418,8 @@ export class Solver {
3469
4418
  break;
3470
4419
  }
3471
4420
  if (dt < giant) {
3472
- dt *= 10; // ramping up after a failure
4421
+ dt *= grow; // ramping up after a failure
4422
+ grow = Math.min(10, grow * 1.5);
3473
4423
  if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
3474
4424
  } else if (dt < 1e6 * giant) {
3475
4425
  dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)