driftlet 0.10.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
@@ -7,6 +7,62 @@ function bvFactor(a, alpha) {
7
7
  return { g, gp: alpha * Math.exp(alpha * a) + (1 - alpha) * e };
8
8
  }
9
9
 
10
+ // GMRES(m), restarted, for a real system op(x) = b, op already preconditioned; x holds the
11
+ // starting guess and receives the answer. Returns whether it converged.
12
+ function gmresReal(op, b, x, { m = 10, restarts = 3, tol = 1e-12 } = {}) {
13
+ const N = b.length;
14
+ const dot = (a, c) => {
15
+ let t = 0;
16
+ for (let j = 0; j < N; j++) t += a[j] * c[j];
17
+ return t;
18
+ };
19
+ const bn = Math.sqrt(dot(b, b)) || 1;
20
+ for (let cycle = 0; cycle < restarts; cycle++) {
21
+ const ax = op(x), r = Float64Array.from(b, (v, j) => v - ax[j]);
22
+ const beta = Math.sqrt(dot(r, r));
23
+ if (!(beta > tol * bn)) return true;
24
+ const V = [r.map((v) => v / beta)], H = Array.from({ length: m + 1 }, () => new Float64Array(m));
25
+ const cs = [], sn = [], g = new Float64Array(m + 1);
26
+ g[0] = beta;
27
+ let k = 0, converged = false;
28
+ for (; k < m; k++) {
29
+ const w = op(V[k]);
30
+ for (let i = 0; i <= k; i++) {
31
+ const h = dot(V[i], w);
32
+ H[i][k] = h;
33
+ for (let j = 0; j < N; j++) w[j] -= h * V[i][j];
34
+ }
35
+ const hn = Math.sqrt(dot(w, w));
36
+ V.push(w.map((v) => v / (hn || 1)));
37
+ for (let i = 0; i < k; i++) {
38
+ const a = H[i][k], c = H[i + 1][k];
39
+ H[i][k] = cs[i] * a + sn[i] * c;
40
+ H[i + 1][k] = -sn[i] * a + cs[i] * c;
41
+ }
42
+ const t = Math.hypot(H[k][k], hn), c = t > 0 ? H[k][k] / t : 1, sv = t > 0 ? hn / t : 0;
43
+ cs.push(c);
44
+ sn.push(sv);
45
+ H[k][k] = t;
46
+ g[k + 1] = -sv * g[k];
47
+ g[k] *= c;
48
+ if (!(Math.abs(g[k + 1]) > tol * bn) || !(hn > 0)) {
49
+ k++;
50
+ converged = true;
51
+ break;
52
+ }
53
+ }
54
+ const y = new Float64Array(k);
55
+ for (let i = k - 1; i >= 0; i--) {
56
+ let v = g[i];
57
+ for (let j = i + 1; j < k; j++) v -= H[i][j] * y[j];
58
+ y[i] = v / H[i][i];
59
+ }
60
+ for (let i = 0; i < k; i++) for (let j = 0; j < N; j++) x[j] += y[i] * V[i][j];
61
+ if (converged) return true;
62
+ }
63
+ return false;
64
+ }
65
+
10
66
  // Solve the K×K complex system (Ar + i Ai) x = (last column), partial pivoting by modulus.
11
67
  function complexSolve(Ar, Ai, K) {
12
68
  for (let col = 0; col < K; col++) {
@@ -39,8 +95,13 @@ function complexSolve(Ar, Ai, K) {
39
95
  }
40
96
 
41
97
  // GMRES(m), restarted, for a complex system op(x) = b, with vectors as [re, im] pairs and op
42
- // already preconditioned; x holds the starting guess and receives the answer.
43
- 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 } = {}) {
44
105
  const N = b[0].length;
45
106
  const cdot = (a, c) => {
46
107
  let re = 0, im = 0;
@@ -52,15 +113,24 @@ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
52
113
  };
53
114
  const norm = (a) => Math.sqrt(cdot(a, a)[0]);
54
115
  const bn = norm(b) || 1;
55
- 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++) {
56
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])];
57
- const beta = norm(r);
58
- if (!(beta > tol * bn)) return;
59
- 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))]];
60
130
  const H = Array.from({ length: m + 1 }, () => Array.from({ length: m }, () => [0, 0]));
61
131
  const cs = [], sn = [], g = Array.from({ length: m + 1 }, () => [0, 0]);
62
- g[0] = [beta, 0];
63
- let k = 0, converged = false;
132
+ g[0] = [beta * bn, 0];
133
+ let k = 0;
64
134
  for (; k < m; k++) {
65
135
  const w = op(V[k]);
66
136
  for (let i = 0; i <= k; i++) {
@@ -91,7 +161,6 @@ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
91
161
  g[k + 1] = [-(s[0] * gr + s[1] * gi), -(s[0] * gi - s[1] * gr)];
92
162
  if (!(Math.hypot(g[k + 1][0], g[k + 1][1]) > tol * bn)) {
93
163
  k++;
94
- converged = true;
95
164
  break;
96
165
  }
97
166
  }
@@ -114,8 +183,10 @@ function gmres(op, b, x, { m = 12, restarts = 4, tol = 1e-8 } = {}) {
114
183
  x[1][j] += yr * V[i][1][j] + yi * V[i][0][j];
115
184
  }
116
185
  }
117
- if (converged) return; // (by its own residual estimate)
118
186
  }
187
+ x[0].set(best[0]);
188
+ x[1].set(best[1]);
189
+ return { initial, residual: least };
119
190
  }
120
191
 
121
192
  // Discretisation and nonlinear solver.
@@ -147,6 +218,7 @@ import { BlockTridiagonal, ComplexBlockTridiagonal } from './blockTridiagonal.js
147
218
  import { bernoulli, bernoulliDerivative } from './bernoulli.js';
148
219
  import { EPS0, FARADAY } from './constants.js';
149
220
  import { nextBreakpoint, sourceAt } from './device.js';
221
+ import { DifferenceTerms } from './difference.js';
150
222
  import { DeviceError } from './errors.js';
151
223
  import { powi, powr } from './pow.js';
152
224
 
@@ -343,6 +415,7 @@ export class Solver {
343
415
  this.time = 0;
344
416
  this.lastDt = Infinity;
345
417
  this.atSteady = false; // whether the state is a converged steady solve
418
+ this.lin = null; // (set while assembling for DifferenceTerms)
346
419
  // Contact bookkeeping, filled by assemble(): particle flux toward +x through each contact,
347
420
  // and the displacement there (the metal's surface charge for a neutral link).
348
421
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
@@ -415,12 +488,6 @@ export class Solver {
415
488
  );
416
489
  this.portRates = model.ports.map((port) => port.reactions.map(() => new Float64Array(port.nodes.length)));
417
490
 
418
- // Whether any region is strictly neutral (ε = 0, not a conductor): see integrate().
419
- this.strictlyNeutral = model.regions.some((reg) => {
420
- const mat = materials[reg.material];
421
- return !mat.conductor && mat.epsr === 0;
422
- });
423
-
424
491
  // Scales the generation reactions' rates during a steady solve's continuation (else 1).
425
492
  this.generationScale = 1;
426
493
  this.hasGeneration = this.rxs.some((rx) => rx.generation);
@@ -460,6 +527,18 @@ export class Solver {
460
527
  }
461
528
  });
462
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
+ });
463
542
  this.combining = false;
464
543
  this.bookkeeping = regions.map((_, r) => {
465
544
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], nodes = [], segs = [];
@@ -505,6 +584,11 @@ export class Solver {
505
584
  const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
506
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]++;
507
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;
508
592
  const N = sys.size;
509
593
  this.SINK = N;
510
594
  const rix = (this.rix = new Int32Array(nB * M).fill(N)), fullOf = (this.fullOf = new Int32Array(N));
@@ -515,6 +599,7 @@ export class Solver {
515
599
  }
516
600
  this.res = new Float64Array(N + 1);
517
601
  this.delta = new Float64Array(N + 1);
602
+ this.rowScale = new Float64Array(N + 1).fill(1); // each row's scaling in the factorised system
518
603
  }
519
604
 
520
605
  _factor() {
@@ -564,6 +649,8 @@ export class Solver {
564
649
  if (reacting.length > 0) reactive = true;
565
650
  const connected = leftOpen || rightOpen || linked.length > 0;
566
651
  this.stretches.push({
652
+ leftOpen,
653
+ rightOpen,
567
654
  linked, // ports that hold or exchange it
568
655
  ports: [...new Set([...linked, ...reacting])], // ports that bring any in
569
656
  species: i,
@@ -594,6 +681,33 @@ export class Solver {
594
681
  st.mobile = true;
595
682
  for (let q = st.regions[0]; q <= st.regions[1]; q++) if (!(materials[regions[q].material].D[st.species] > 0)) st.mobile = false;
596
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;
597
711
  // Conserved amounts solved directly, each in place of one (redundant) balance row: a
598
712
  // spectator's own amount, and each conserved combination of reacting stretches (the total
599
713
  // iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose weighted balances sum to zero at steady state.
@@ -652,6 +766,66 @@ export class Solver {
652
766
  reference: new Float64Array(sts[0].nodes[1] - sts[0].nodes[0] + 1),
653
767
  });
654
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));
655
829
  this.constrained = false;
656
830
  }
657
831
 
@@ -668,6 +842,71 @@ export class Solver {
668
842
  }
669
843
  }
670
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
+
671
910
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
672
911
  _applyLocalConstraints() {
673
912
  const { n, M, c, res, dA: d } = this;
@@ -815,7 +1054,8 @@ export class Solver {
815
1054
 
816
1055
  // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
817
1056
  // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
818
- // 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
819
1059
  // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
820
1060
  // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
821
1061
  // system for the μ (pins) and δV (terminals):
@@ -882,6 +1122,13 @@ export class Solver {
882
1122
  }
883
1123
 
884
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
+
885
1132
  /** Amount (mol per unit area) of surface column j's species, Γ Σ v·a·θ, in the current state. */
886
1133
  surfaceAmount(j) {
887
1134
  const [k, q] = this.surfCols[j], port = this.model.ports[k], vol = this.model.grid.vol;
@@ -1129,13 +1376,7 @@ export class Solver {
1129
1376
  const shift = level(t.side) - level(this.terms[held].side);
1130
1377
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1131
1378
  }
1132
- // Electrode surfaces at their starting coverages: η = μ°/RT + ln(θ/θ₀).
1133
- for (let g = 0; g < this.nNodes; g++) {
1134
- const k = this.surfPort[g];
1135
- if (k < 0) continue;
1136
- const surf = model.ports[k].surface, b = this.blockOfNode[g], bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1137
- surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1138
- }
1379
+ for (let g = 0; g < this.nNodes; g++) if (this.surfPort[g] >= 0) this._surfaceStart(g);
1139
1380
  this.computeConcentrations();
1140
1381
  // A floating capacitance starts uncharged (σ = 0 at the window's mean φ), as the starting
1141
1382
  // composition, neutral without it, assumes; with reactions too, its double layer then charges
@@ -1360,6 +1601,7 @@ export class Solver {
1360
1601
  const { grid, materials, regions } = model;
1361
1602
  sys.clear();
1362
1603
  res.fill(0);
1604
+ if (this.lin) this.lin.reset(1 / dt);
1363
1605
  this.computeConcentrations();
1364
1606
 
1365
1607
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -1391,8 +1633,13 @@ export class Solver {
1391
1633
  // Terminals: ports (after every other term at their nodes, so a held level can read its
1392
1634
  // flux), then contacts, then the circuit rows of the floating ones.
1393
1635
  this._terminals(dt);
1636
+ if (this.flattening && dt === Infinity) this._flatRows();
1637
+ if (this.flattening && this.constrained && dt === Infinity) this._levelRows();
1394
1638
  if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1395
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] : [];
1396
1643
  this.transformed = this.combining && dt !== Infinity;
1397
1644
  if (this.transformed) this._chargeRows(dt);
1398
1645
  }
@@ -1412,7 +1659,7 @@ export class Solver {
1412
1659
  // charge in the Gauss row, written straight into the diagonal blocks by local index. A
1413
1660
  // dielectric is the case with no species.
1414
1661
  _nodesDilute(g0, g1, dt) {
1415
- 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;
1416
1663
  const JB = sys.B, sizes = sys.sizes, offB = sys.offB, vol = this.model.grid.vol;
1417
1664
  for (let g = g0; g <= g1; g++) {
1418
1665
  const b = blockOfNode[g], v = vol[g];
@@ -1423,22 +1670,27 @@ export class Solver {
1423
1670
  continue;
1424
1671
  }
1425
1672
  let q = rhoFixed[g], dq = 0;
1673
+ const late = this.combining && this.lateStorage[g] === 1 && dt !== Infinity; // (the ions' storage: in _contact)
1426
1674
  for (let i = 0; i < n; i++) {
1427
1675
  const k = g * n + i;
1428
1676
  if (!present[k]) continue;
1429
- const ck = c[k], l = loc[lb + 1 + i];
1430
- res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
1431
- 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;
1432
1679
  q += F * z[i] * ck;
1433
1680
  dq += F * z[i] * z[i] * ck;
1434
- if (p >= 0 && z[i] !== 0) {
1435
- JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
1436
- 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;
1437
1688
  }
1689
+ if (p >= 0 && z[i] !== 0) JB[oB + p * m + l] += -v * F * z[i] * ck;
1438
1690
  }
1439
1691
  if (p >= 0) {
1440
1692
  res[R[lb]] -= v * q;
1441
- JB[oB + p * m + p] += v * dq;
1693
+ if (!lin) JB[oB + p * m + p] += v * dq;
1442
1694
  }
1443
1695
  }
1444
1696
  }
@@ -1525,6 +1777,7 @@ export class Solver {
1525
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];
1526
1778
  res[rk] += w * res[ri];
1527
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);
1528
1781
  }
1529
1782
  // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
1530
1783
  // unknowns appear (its own rows, its neighbours', a terminal's current).
@@ -1651,6 +1904,7 @@ export class Solver {
1651
1904
  const mL = sys.sizes[bL], mR = sys.sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1652
1905
  res[R[bL * M]] += D;
1653
1906
  res[R[bR * M]] -= D;
1907
+ if (this.lin) return this.lin.displacement(R[bL * M], R[bR * M], k);
1654
1908
  sys.B[sys.offB[bL] + pL * mL + pL] += k;
1655
1909
  sys.C[sys.offC[bL] + pL * mR + pR] -= k;
1656
1910
  sys.A[sys.offA[bR] + pR * mL + pL] -= k;
@@ -1664,7 +1918,7 @@ export class Solver {
1664
1918
  // fluxes (e.g. majority carriers carrying a small current) don't vanish in the cancellation of
1665
1919
  // two huge drift and diffusion terms.
1666
1920
  _segmentsDilute(g0, g1, region, mat, vel) {
1667
- 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;
1668
1922
  const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1669
1923
  const { segLength, segArea } = this.model.grid;
1670
1924
  for (let s = g0; s < g1; s++) {
@@ -1689,6 +1943,10 @@ export class Solver {
1689
1943
  const dNdPhiR = zi * dNdd;
1690
1944
  res[R[bL * M + r]] += N;
1691
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
+ }
1692
1950
  // Row r of block bL (couplings to itself in B, to bR in C) and of bR (to bL in A).
1693
1951
  const lL = loc[bL * M + r], lR = loc[bR * M + r];
1694
1952
  const oBL = offB[bL] + lL * mL, oCL = offC[bL] + lL * mR, oAR = offA[bR] + lR * mL, oBR = offB[bR] + lR * mR;
@@ -2121,6 +2379,7 @@ export class Solver {
2121
2379
  _captureRow(b, s, w, into) {
2122
2380
  const l = this.loc[b * this.M + s];
2123
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);
2124
2383
  const { sys, nB } = this, { sizes, offA, offB, offC, offX } = sys, m = sizes[b];
2125
2384
  for (const [X, nb, off] of [['A', b - 1, offA], ['B', b, offB], ['C', b + 1, offC]]) {
2126
2385
  if (nb < 0 || nb >= nB) continue;
@@ -2323,6 +2582,19 @@ export class Solver {
2323
2582
  this._captureRow(b, 1 + i, z[i] * F, C);
2324
2583
  }
2325
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
+ }
2326
2598
 
2327
2599
  // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2328
2600
  // links (neutral species): N_in = k (μ_out − μ)/RT.
@@ -2372,7 +2644,7 @@ export class Solver {
2372
2644
  // The Poisson residual is the outside's charge, D_in.
2373
2645
  const Din = res[R[b * M]];
2374
2646
  this.contactD[side] = sgn * Din;
2375
- if (dyn) {
2647
+ if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2376
2648
  I += (Din - Dstart) / dt;
2377
2649
  this._captureRow(b, 0, 1 / dt, C);
2378
2650
  }
@@ -2405,10 +2677,59 @@ export class Solver {
2405
2677
 
2406
2678
 
2407
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
+
2408
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
+ }
2409
2729
  _replaceRow(b, rs) {
2410
2730
  const l = this.loc[b * this.M + rs];
2411
2731
  if (l < 0) return;
2732
+ if (this.lin) this.lin.kill(this.rix[b * this.M + rs]);
2412
2733
  const { sys, nB } = this, sz = sys.sizes, m = sz[b];
2413
2734
  sys.B.fill(0, sys.offB[b] + l * m, sys.offB[b] + (l + 1) * m);
2414
2735
  if (b > 0) sys.A.fill(0, sys.offA[b] + l * sz[b - 1], sys.offA[b] + (l + 1) * sz[b - 1]);
@@ -2427,8 +2748,12 @@ export class Solver {
2427
2748
  for (let k = 0; k < mp; k++) mx = Math.max(mx, Math.abs(A[oa + k]));
2428
2749
  for (let k = 0; k < m; k++) mx = Math.max(mx, Math.abs(B[ob + k]));
2429
2750
  for (let k = 0; k < mn; k++) mx = Math.max(mx, Math.abs(C[oc + k]));
2430
- if (mx === 0) continue;
2751
+ if (mx === 0) {
2752
+ this.rowScale[offX[b] + r] = 1;
2753
+ continue;
2754
+ }
2431
2755
  const s = 1 / mx;
2756
+ this.rowScale[offX[b] + r] = s;
2432
2757
  for (let k = 0; k < mp; k++) A[oa + k] *= s;
2433
2758
  for (let k = 0; k < m; k++) B[ob + k] *= s;
2434
2759
  for (let k = 0; k < mn; k++) C[oc + k] *= s;
@@ -2438,6 +2763,111 @@ export class Solver {
2438
2763
  }
2439
2764
  }
2440
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
+
2441
2871
  // Largest update among the potential-like unknowns (φ̂ and η at grid nodes).
2442
2872
  _maxPotentialStep(delta, deltaV = this.deltaV) { // (deltaV null: the device's potentials only)
2443
2873
  const { n, M } = this;
@@ -2460,16 +2890,55 @@ export class Solver {
2460
2890
  * Newton iteration for one backward-Euler step from the current cOld.
2461
2891
  * @returns {{converged: boolean, iterations: number, history: number[]}}
2462
2892
  */
2463
- newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10 } = {}) {
2893
+ newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands = false } = {}) {
2464
2894
  const { delta, res } = this;
2465
2895
  const deltaV = this.deltaV ?? (this.deltaV = new Float64Array(this.floating.length));
2466
2896
  const history = [], ownHistory = [];
2467
- // 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.
2468
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
+ }
2469
2931
  if (dt === Infinity) this._noteConditioning();
2470
2932
  return r;
2471
2933
  };
2472
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
+ }
2473
2942
  try {
2474
2943
  this.assemble(dt);
2475
2944
  } catch (err) {
@@ -2486,9 +2955,13 @@ export class Solver {
2486
2955
  return fail({ converged: false, iterations: it, history, error: err.message });
2487
2956
  }
2488
2957
  try {
2489
- const pins = this.constrained && dt === Infinity ? this.constraints : [];
2958
+ const pins = this.pins;
2490
2959
  deltaV.fill(0);
2491
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
+ }
2492
2965
  if (this.transformed) this._untransform(delta);
2493
2966
  } catch (err) {
2494
2967
  if (!(err instanceof SolverError)) throw err;
@@ -2505,7 +2978,7 @@ export class Solver {
2505
2978
  ownHistory.push(own);
2506
2979
  if (!Number.isFinite(step)) return fail({ converged: false, iterations: it, history, error: 'non-finite update' });
2507
2980
  // Give up early on clear divergence; the caller will take a smaller step instead.
2508
- if (own > 1e4 || (it > 6 && own > 10 * ownHistory[0])) {
2981
+ if ((it > 1 && own > 1e4) || (it > 6 && own > 10 * ownHistory[0])) {
2509
2982
  this.computeConcentrations();
2510
2983
  return fail({ converged: false, iterations: it, history, error: 'diverging' });
2511
2984
  }
@@ -2513,8 +2986,10 @@ export class Solver {
2513
2986
  this._addToState(delta, -alpha);
2514
2987
  this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
2515
2988
  if (alpha === 1 && step < tol) {
2989
+ const done = { converged: true, iterations: it, history, residual: rmax };
2990
+ if (islandsOff(it, done)) continue;
2516
2991
  this.computeConcentrations();
2517
- return { converged: true, iterations: it, history, residual: rmax };
2992
+ return done;
2518
2993
  }
2519
2994
  // Converged as far as round-off allows: the updates are already tiny (below 1e-6 thermal
2520
2995
  // units, ~26 nV) and have stopped shrinking. A badly conditioned system's floor can sit
@@ -2523,15 +2998,35 @@ export class Solver {
2523
2998
  // (Raising this floor lets a weakly held population drift: a MOS capacitor without a
2524
2999
  // channel port then showed a DC leak through its oxide.)
2525
3000
  const [p1, p2] = [history[history.length - 2], history[history.length - 3]], floor = 1e-6;
2526
- 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;
2527
3011
  this.computeConcentrations();
2528
- return { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3012
+ return done;
2529
3013
  }
2530
3014
  }
2531
3015
  this.computeConcentrations();
2532
3016
  return fail({ converged: false, iterations: maxIter, history });
2533
3017
  }
2534
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
+
2535
3030
  // How many digits the last factorisation lost to cancellation, and where (the worst seen since
2536
3031
  // the steady solve began). Only for diagnosing failures: a saturated species, for instance,
2537
3032
  // can lose digits harmlessly.
@@ -2648,6 +3143,20 @@ export class Solver {
2648
3143
  let result;
2649
3144
  try {
2650
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
+ }
2651
3160
  } finally {
2652
3161
  this.combining = false;
2653
3162
  }
@@ -2690,11 +3199,11 @@ export class Solver {
2690
3199
  if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
2691
3200
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2692
3201
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2693
- let grow = 0; // longer first steps tried after a Newton failure (see below)
2694
3202
  // The shortest step worth trying: round-off relative to the time, or the device's fastest
2695
3203
  // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
2696
3204
  const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
2697
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)
2698
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);
2699
3208
  while (this.time < tEnd) {
2700
3209
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
@@ -2719,6 +3228,7 @@ export class Solver {
2719
3228
  this.landing = atBreak ? tb : clamped ? tEnd : undefined;
2720
3229
  const snap = this._snapshot();
2721
3230
  let err, p, half = null;
3231
+ if (this.history.length > 0) firstErrs.length = 0;
2722
3232
  if (this.history.length === 0) {
2723
3233
  // First step: backward Euler, checked against two half steps (whose result is kept).
2724
3234
  const full = this.step(h);
@@ -2739,17 +3249,7 @@ export class Solver {
2739
3249
  if (err === undefined) {
2740
3250
  this._restore(snap);
2741
3251
  rejected++;
2742
- // Newton failed on the first step after a start or a jump. Usually a shorter step
2743
- // helps; but right after a jump in a strictly neutral (ε = 0) material, the shorter the
2744
- // step the worse conditioned it is (the field is fixed only through fluxes, which the
2745
- // storage term dwarfs), so try longer ones first.
2746
- if (this.strictlyNeutral && grow < 3 && h * 16 < tEnd - this.time) {
2747
- grow++;
2748
- dt = h * 16;
2749
- } else {
2750
- grow = 3;
2751
- dt = h / 4;
2752
- }
3252
+ dt = h / 4;
2753
3253
  if (dt < floor) { failed = true; break; }
2754
3254
  continue;
2755
3255
  }
@@ -2767,14 +3267,20 @@ export class Solver {
2767
3267
  err = this._errorNorm(pred) * p.scale;
2768
3268
  p = p.order;
2769
3269
  }
2770
- 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) {
2771
3278
  this._restore(snap);
2772
3279
  rejected++;
2773
3280
  dt = h * factor(err, p);
2774
3281
  continue;
2775
3282
  }
2776
3283
  steps++;
2777
- grow = 0;
2778
3284
  // The state the first step started from needn't satisfy the algebraic equations (φ in a
2779
3285
  // strictly neutral region, an interface's unknowns, after a start or a jump), and they
2780
3286
  // jump in the first instant; a predictor through it would see that jump as error on every
@@ -3034,52 +3540,17 @@ export class Solver {
3034
3540
  for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
3035
3541
  const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
3036
3542
  const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
3037
- // J·v + (∂res/∂V_k)σ from the residual itself, by a central difference: the assembled J holds
3038
- // a flux's dependence on η_L and η_R as two entries, and where both are huge (an inversion
3039
- // layer) and v nearly uniform, J·v loses the flux to round-off; the residual takes the η
3040
- // difference first, in double-double. So η moves in the low word, φ̂ and the flux unknowns
3041
- // in the high one (φ̂ only scales expm1(Δη), near zero there).
3042
- const u0 = Float64Array.from(this.u), uLo0 = Float64Array.from(this.uLo), V0 = Float64Array.from(this.termV);
3043
- // The unknowns perturbed in the low word: a node's η (not φ̂, a metal's segment flux, or a
3044
- // face's unknowns, which are read from the high word).
3045
- const low = new Uint8Array(this.fullOf.length);
3046
- const nodeBlock = new Uint8Array(nB);
3047
- for (let g = 0; g < this.nNodes; g++) if (this.nodeConductor[g] < 0) nodeBlock[this.blockOfNode[g]] = 1;
3048
- for (let j = 0; j < low.length; j++) {
3049
- const full = this.fullOf[j];
3050
- low[j] = nodeBlock[Math.floor(full / M)] && full % M !== 0 ? 1 : 0;
3051
- }
3052
- const resP = new Float64Array(this.res.length), resM = new Float64Array(this.res.length);
3053
- const derivative = (v, k, sigma, out) => {
3054
- let mx = Math.abs(sigma);
3055
- for (let j = 0; j < v.length; j++) mx = Math.max(mx, Math.abs(v[j]));
3056
- const h = 1e-4 / (mx || 1);
3057
- for (const [sgn, into] of [[1, resP], [-1, resM]]) {
3058
- this.u.set(u0);
3059
- this.uLo.set(uLo0);
3060
- this.termV.set(V0);
3061
- for (let j = 0; j < v.length; j++) {
3062
- const full = this.fullOf[j];
3063
- if (low[j]) this.uLo[full] += sgn * h * v[j];
3064
- else this.u[full] += sgn * h * v[j];
3065
- }
3066
- // A held terminal's voltage comes from its source at each assembly; a floating one's is
3067
- // an unknown.
3068
- if (k >= 0 && fl.includes(k)) this.termV[k] += sgn * h * sigma;
3069
- else if (k >= 0) this.sourceOverride.set(k, V0[k] + sgn * h * sigma);
3070
- this.assemble(Infinity);
3071
- if (k >= 0) this.sourceOverride.delete(k);
3072
- into.set(this.res);
3073
- }
3074
- for (let j = 0; j < out.length; j++) out[j] = (resP[j] - resM[j]) / (2 * h);
3075
- };
3076
- const restore = () => {
3077
- this.u.set(u0);
3078
- this.uLo.set(uLo0);
3079
- this.termV.set(V0);
3080
- this._refreshSources();
3081
- this.computeConcentrations();
3082
- };
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);
3083
3554
 
3084
3555
  const { sizes, offA, offB, offC, offX } = sys;
3085
3556
  const csys = new ComplexBlockTridiagonal(nB, sizes);
@@ -3125,9 +3596,6 @@ export class Solver {
3125
3596
  }
3126
3597
  }
3127
3598
  csys.factor();
3128
- // Each solve uses that residual-based J·v (S·v is safe from the matrix): in an inversion
3129
- // layer fed by minority carriers that number ~1e3 cm⁻³ in the bulk, the assembled J alone
3130
- // gives the layer an exchange path many orders too fast.
3131
3599
  const sx = (X, vr, out) => {
3132
3600
  for (let blk = 0; blk < nB; blk++) {
3133
3601
  const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
@@ -3153,25 +3621,35 @@ export class Solver {
3153
3621
  csys.solve(rRes, iRes, outR, outI);
3154
3622
  return [outR, outI];
3155
3623
  };
3156
- const op = ([vr, vi]) => {
3157
- derivative(vr, -1, 0, jr);
3158
- derivative(vi, -1, 0, ji);
3159
- sx(S, vi, si);
3160
- sx(S, vr, sr);
3161
- 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);
3162
3630
  for (let j = 0; j < Nc; j++) {
3163
3631
  ar[j] = jr[j] - w * si[j];
3164
3632
  ai[j] = ji[j] + w * sr[j];
3165
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);
3166
3642
  return precondition(ar, ai);
3167
3643
  };
3168
3644
  const solve = (br, bi, outr, outi, k, sigma) => {
3169
3645
  const rhs = k >= 0 ? precondition(Bt[k].map((v) => -sigma * v), zero) : [new Float64Array(Nc), new Float64Array(Nc)];
3170
3646
  outr.set(rhs[0]);
3171
3647
  outi.set(rhs[1]);
3172
- gmres(op, rhs, [outr, outi]);
3173
- 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);
3174
3651
  };
3652
+ let accuracy = 1e-14; // (relative, of the solves: the readouts' error is weighed with it)
3175
3653
  // y: the response with the floating terminals held; X_k: to a unit δV_k.
3176
3654
  const heldT = dT.kind === 'V';
3177
3655
  for (let j = 0; j < Nc; j++) {
@@ -3211,12 +3689,93 @@ export class Solver {
3211
3689
  xr[this.fullOf[j]] = re;
3212
3690
  xi[this.fullOf[j]] = im;
3213
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
+ };
3214
3742
  let Zr, Zi;
3215
3743
  if (heldT) {
3216
- // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal
3217
- let [Ir, Ii] = dot(kT, w, yr, yi);
3218
- Ir += DI[kT];
3219
- 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
+ }
3220
3779
  const d2 = Ir * Ir + Ii * Ii; // Z = 1/δI
3221
3780
  Zr = Ir / d2;
3222
3781
  Zi = -Ii / d2;
@@ -3233,6 +3792,125 @@ export class Solver {
3233
3792
  return out;
3234
3793
  }
3235
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
+
3236
3914
  // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
3237
3915
  _smallSignalProfiles(xr, xi) {
3238
3916
  const { n, M, z, VT, model } = this;
@@ -3362,6 +4040,16 @@ export class Solver {
3362
4040
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
3363
4041
  // a change of drive).
3364
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) {
3365
4053
  // A terminal that passes current only by charging (a gate, a capacitance with no species
3366
4054
  // through it) has no steady state under a current drive: it charges for ever, or at I = 0
3367
4055
  // keeps whatever charge it started with, which a steady solve doesn't know.
@@ -3399,25 +4087,44 @@ export class Solver {
3399
4087
  if (r.converged) return r;
3400
4088
  // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
3401
4089
  // nothing, each solve warm from the last.
4090
+ let dimmer = false;
3402
4091
  if (this.hasGeneration && opts.continuation !== false) {
3403
4092
  this.u.set(u0);
3404
4093
  this.uLo.set(u0Lo);
3405
4094
  this.termV.set(v0);
3406
4095
  this.computeConcentrations();
4096
+ this.dimmer = false;
3407
4097
  const g = this._generationContinuation(opts, r);
4098
+ dimmer = this.dimmer;
3408
4099
  if (g.converged) {
3409
4100
  this.solvedV = target;
3410
4101
  return g;
3411
4102
  }
3412
4103
  r = g;
3413
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
+ }
3414
4116
  if (!canContinue) {
3415
4117
  if (quick) {
3416
- this.u.set(u0);
3417
- this.uLo.set(u0Lo);
3418
- 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
+ }
3419
4126
  this.computeConcentrations();
3420
- const full = this._solveSteady(opts); // the full pseudo-transient ramp
4127
+ const full = this._solveSteady(opts);
3421
4128
  return { ...full, steps: full.steps + r.steps, iterations: full.iterations + r.iterations };
3422
4129
  }
3423
4130
  return r;
@@ -3445,18 +4152,121 @@ export class Solver {
3445
4152
  return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
3446
4153
  }
3447
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
+
3448
4256
  // Steady solves with the generation reactions' rates scaled from 1e-12 up to 1, ×100 a step
3449
4257
  // while they converge, smaller steps where they don't.
3450
4258
  _generationContinuation(opts, r) {
3451
4259
  const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
3452
- 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;
3453
4261
  const history = r.history.slice();
3454
4262
  try {
3455
4263
  this.generationScale = s;
4264
+ // (nearly dark: a cold start can need the full ramp, as a dark device's does)
3456
4265
  let q = this._solveSteady(sub);
3457
4266
  steps += q.steps;
3458
4267
  iterations += q.iterations;
3459
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)
3460
4270
  while (s < 1) {
3461
4271
  const next = Math.min(1, s * factor);
3462
4272
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
@@ -3474,7 +4284,7 @@ export class Solver {
3474
4284
  this.termV.set(v1);
3475
4285
  this.computeConcentrations();
3476
4286
  factor = Math.sqrt(factor);
3477
- 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)
3478
4288
  }
3479
4289
  }
3480
4290
  return { converged: true, steps, iterations, history };
@@ -3492,6 +4302,17 @@ export class Solver {
3492
4302
  let q;
3493
4303
  if (!warm) {
3494
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);
3495
4316
  steps += q.steps;
3496
4317
  iterations += q.iterations;
3497
4318
  if (!q.converged) return { ...r, steps, iterations };
@@ -3540,7 +4361,8 @@ export class Solver {
3540
4361
  const direct = this._directSteady();
3541
4362
  const giant = direct ? Infinity : 1e6 * tau;
3542
4363
  if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
3543
- 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;
3544
4366
  let totalIter = 0, steps = 0, converged = false;
3545
4367
  const history = [];
3546
4368
  while (steps < maxSteps) {
@@ -3561,6 +4383,7 @@ export class Solver {
3561
4383
  // Pseudo-transient continuation: down from a slow time scale until a step converges,
3562
4384
  // as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
3563
4385
  dt = dt >= giant ? tau * 1e-6 : dt / 4;
4386
+ grow = 2;
3564
4387
  if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
3565
4388
  continue;
3566
4389
  }
@@ -3568,6 +4391,21 @@ export class Solver {
3568
4391
  converged = true; // the steady equations themselves were solved
3569
4392
  break;
3570
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
+ }
3571
4409
  if (dt >= giant && this._maxPotentialStep(this._diff()) < tol) {
3572
4410
  converged = true;
3573
4411
  if (dt > giant) {
@@ -3580,7 +4418,8 @@ export class Solver {
3580
4418
  break;
3581
4419
  }
3582
4420
  if (dt < giant) {
3583
- dt *= 10; // ramping up after a failure
4421
+ dt *= grow; // ramping up after a failure
4422
+ grow = Math.min(10, grow * 1.5);
3584
4423
  if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
3585
4424
  } else if (dt < 1e6 * giant) {
3586
4425
  dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)