driftlet 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
 
@@ -189,17 +261,39 @@ export class SolverError extends Error {
189
261
  }
190
262
  }
191
263
 
264
+ // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
265
+ function gateRate(law, V) {
266
+ const { rate, midpoint, scale } = law;
267
+ if (law.type === 'exp') {
268
+ const v = rate * Math.exp((V - midpoint) / scale);
269
+ return [v, v / scale];
270
+ }
271
+ if (law.type === 'sigmoid') {
272
+ const e = Math.exp((midpoint - V) / scale), d = 1 + e;
273
+ return Number.isFinite(e) ? [rate / d, (rate * e) / (scale * d * d)] : [0, 0];
274
+ }
275
+ // expLinear: rate·y/(1 − e^−y), smooth through y = 0 (rate there) and → rate·y for large y
276
+ const y = (V - midpoint) / scale;
277
+ if (Math.abs(y) < 1e-6) return [rate * (1 + y / 2), rate / (2 * scale)];
278
+ if (y < -700) return [0, 0];
279
+ const em = -Math.expm1(-y), ey = Math.exp(-y); // 1 − e^−y, e^−y
280
+ return [(rate * y) / em, (rate * (em - y * ey)) / (em * em * scale)];
281
+ }
282
+
192
283
  export class Solver {
193
284
  constructor(model) {
194
285
  this.model = model;
195
286
  const { grid, species, materials, regions, contacts } = model;
196
287
  const n = species.length;
197
288
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
198
- // one per face reaction (its rate), as many as the busiest face has.
199
- const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
289
+ // one per face reaction (its rate) and one per gate (its fraction open), as many as the
290
+ // busiest face has.
291
+ const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length + itf.gates.length), 0);
200
292
  // (and at an electrode port's nodes, one per surface species: its coverage's η)
201
293
  const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
202
- const M = n + 1 + Math.max(nRx, nSurf);
294
+ // (and a membrane port's gates, after them: each one's fraction open)
295
+ const nPortSlots = model.ports.reduce((m, port) => Math.max(m, port.surface.length + port.gates.length), 0);
296
+ const M = n + 1 + Math.max(nRx, nPortSlots);
203
297
  const nNodes = grid.nNodes;
204
298
  const nFaces = regions.length - 1;
205
299
  const nB = nNodes + nFaces;
@@ -234,6 +328,20 @@ export class Solver {
234
328
  // The bare fraction θ₀, directly (1 − Σθ cancels as θ → 1); 1 where there's no surface.
235
329
  this.th0 = new Float64Array(nNodes).fill(1);
236
330
  this.th0Old = new Float64Array(nNodes).fill(1);
331
+ // Gates: each one's slot (at a face, after its reaction rates; at a membrane port's nodes, after
332
+ // the surface's coverages) and value at the step's start. Faces' first, then ports', node by
333
+ // node through each window.
334
+ this.gatePort = new Int32Array(nNodes).fill(-1);
335
+ model.ports.forEach((port, k) => port.gates.length > 0 && port.nodes.forEach((g) => (this.gatePort[g] = k)));
336
+ this.gateList = [
337
+ ...model.interfaces.flatMap((itf, f) => itf.gates.map((gate, q) => ({ f, q, gate, o: this.blockOfFace[f] * M + 1 + n + itf.reactions.length + q }))),
338
+ ...model.ports.flatMap((port, k) => Array.from(port.nodes).flatMap((g) => port.gates.map((gate, q) => ({ port: k, g, q, gate, o: this.blockOfNode[g] * M + 1 + n + port.surface.length + q })))),
339
+ ];
340
+ this.gateOld = new Float64Array(this.gateList.length);
341
+ this.gateIndex = new Int32Array(nFaces); // face f's first gate in gateList
342
+ let j = 0;
343
+ for (let f = 0; f < nFaces; j += model.interfaces[f].gates.length, f++) this.gateIndex[f] = j;
344
+ this.portGateIndex = model.ports.map((port) => ((j += port.nodes.length * port.gates.length), j - port.nodes.length * port.gates.length));
237
345
 
238
346
  // Per-node material data, flattened [g·n + i].
239
347
  this.present = new Uint8Array(nNodes * n);
@@ -343,6 +451,7 @@ export class Solver {
343
451
  this.time = 0;
344
452
  this.lastDt = Infinity;
345
453
  this.atSteady = false; // whether the state is a converged steady solve
454
+ this.lin = null; // (set while assembling for DifferenceTerms)
346
455
  // Contact bookkeeping, filled by assemble(): particle flux toward +x through each contact,
347
456
  // and the displacement there (the metal's surface charge for a neutral link).
348
457
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
@@ -415,12 +524,6 @@ export class Solver {
415
524
  );
416
525
  this.portRates = model.ports.map((port) => port.reactions.map(() => new Float64Array(port.nodes.length)));
417
526
 
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
527
  // Scales the generation reactions' rates during a steady solve's continuation (else 1).
425
528
  this.generationScale = 1;
426
529
  this.hasGeneration = this.rxs.some((rx) => rx.generation);
@@ -460,6 +563,18 @@ export class Solver {
460
563
  }
461
564
  });
462
565
  this.chargeNodes = Int32Array.from([...this.chargeNode.keys()].filter((g) => this.chargeNode[g]));
566
+ // An end node of a strictly neutral region that its contact holds (leaving it neutral: φ law
567
+ // bulk or neutral): the contact's current is the ions' charge passing, and their storage
568
+ // carries none under neutrality, nor does the box's net charge change (a bulk law's "D").
569
+ // Read from the box's balance, both come with round-off ∝ c/dt that cancels only roughly,
570
+ // which on short steps swamps the flux (a floating bath on a dilute side rattled). So there,
571
+ // on a transient step, the ions' storage goes in only after the contact has read its current,
572
+ // and the box's charge isn't counted as displacement (see _contact).
573
+ this.lateStorage = new Uint8Array(nNodes);
574
+ [0, nNodes - 1].forEach((g, k) => {
575
+ const mat = materials[regions[grid.nodeRegion[g]].material], law = contacts[k === 0 ? 'left' : 'right'].phi.type;
576
+ if (!mat.conductor && mat.epsr === 0 && (law === 'bulk' || law === 'neutral') && !this.chargeNode[g] && !this.phiUndefined[g] && this.nodeIdeal[g]) this.lateStorage[g] = 1;
577
+ });
463
578
  this.combining = false;
464
579
  this.bookkeeping = regions.map((_, r) => {
465
580
  const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], nodes = [], segs = [];
@@ -495,16 +610,23 @@ export class Solver {
495
610
  for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
496
611
  const k = this.surfPort[g];
497
612
  if (k >= 0) for (let q = 0; q < model.ports[k].surface.length; q++) active[b * M + 1 + n + q] = 1;
613
+ const kg = this.gatePort[g], pg = model.ports[kg];
614
+ if (kg >= 0) for (let q = 0; q < pg.gates.length; q++) active[b * M + 1 + n + pg.surface.length + q] = 1;
498
615
  }
499
616
  model.interfaces.forEach((itf, f) => {
500
617
  const bf = this.blockOfFace[f];
501
618
  active[bf * M] = itf.phi.type === 'neutral' ? 0 : 1;
502
619
  for (let i = 0; i < n; i++) active[bf * M + 1 + i] = itf.links[i].type === 'blocked' ? 0 : 1;
503
- for (let k = 0; k < itf.reactions.length; k++) active[bf * M + 1 + n + k] = 1;
620
+ for (let k = 0; k < itf.reactions.length + itf.gates.length; k++) active[bf * M + 1 + n + k] = 1;
504
621
  });
505
622
  const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
506
623
  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
624
  const sys = (this.sys = new BlockTridiagonal(nB, sizes));
625
+ // A pivot that cancels to exactly nothing (a population held only through conductances
626
+ // 1e-16 of its neighbours', as GaAs's minority carriers ~1 per m³ beside a face) is
627
+ // perturbed, not thrown on: Newton then converges, its small updates meaning small residuals
628
+ // (the factorisation only amplifies), refining its solves where they stall.
629
+ sys.staticPivots = true;
508
630
  const N = sys.size;
509
631
  this.SINK = N;
510
632
  const rix = (this.rix = new Int32Array(nB * M).fill(N)), fullOf = (this.fullOf = new Int32Array(N));
@@ -515,6 +637,7 @@ export class Solver {
515
637
  }
516
638
  this.res = new Float64Array(N + 1);
517
639
  this.delta = new Float64Array(N + 1);
640
+ this.rowScale = new Float64Array(N + 1).fill(1); // each row's scaling in the factorised system
518
641
  }
519
642
 
520
643
  _factor() {
@@ -564,6 +687,8 @@ export class Solver {
564
687
  if (reacting.length > 0) reactive = true;
565
688
  const connected = leftOpen || rightOpen || linked.length > 0;
566
689
  this.stretches.push({
690
+ leftOpen,
691
+ rightOpen,
567
692
  linked, // ports that hold or exchange it
568
693
  ports: [...new Set([...linked, ...reacting])], // ports that bring any in
569
694
  species: i,
@@ -594,6 +719,33 @@ export class Solver {
594
719
  st.mobile = true;
595
720
  for (let q = st.regions[0]; q <= st.regions[1]; q++) if (!(materials[regions[q].material].D[st.species] > 0)) st.mobile = false;
596
721
  }
722
+ // Stretches whose steady state is known outright: a species that no reaction or port touches,
723
+ // reached by one contact only (the other end blocked), carries no flux at steady state, so its
724
+ // level is flat at that contact's. Steady solves pin it there (_flatRows) rather than find it
725
+ // through its own conduction, which can be all but nothing: a MOS capacitor's inversion
726
+ // electrons reach the back contact only through a bulk with ~1e3 of them per cm³. (Not
727
+ // through flow or mixing, nor a concentrated material's cross-diffusion, where zero flux
728
+ // isn't a flat level.)
729
+ this.flatStretches = this.stretches.filter((st) => {
730
+ if (st.reactive || st.ports.length > 0 || !st.mobile || st.leftOpen === st.rightOpen) return false;
731
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
732
+ const reg = regions[q], mat = materials[reg.material];
733
+ if (mat.conductor || !mat.ideal || reg.velocity !== 0 || reg.mixing > 0) return false;
734
+ }
735
+ return true;
736
+ });
737
+ // Likewise a spectator that's mobile throughout: no flux at steady state, so its level is flat,
738
+ // at a value its amount fixes. Steady solves hold its levels equal (_levelRows) rather than
739
+ // find them through its own conduction: blocked SO₄²⁻ driven from a zinc cathode's extended
740
+ // space charge is down to 1e-25 mol/m³ there, and the chain of conductances through it lost
741
+ // the steady system 14 digits. (Within one region: across a face, the rows that would hold
742
+ // the level and pin the face's flux leave a diagonal block singular.)
743
+ this.levelStretches = this.stretches.filter((st) => {
744
+ if (!st.spectator || !st.mobile || st.ports.length > 0 || st.regions[0] !== st.regions[1]) return false;
745
+ const reg = regions[st.regions[0]], mat = materials[reg.material];
746
+ return !mat.conductor && mat.ideal && reg.velocity === 0 && !(reg.mixing > 0);
747
+ });
748
+ this.flattening = false;
597
749
  // Conserved amounts solved directly, each in place of one (redundant) balance row: a
598
750
  // spectator's own amount, and each conserved combination of reacting stretches (the total
599
751
  // iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose weighted balances sum to zero at steady state.
@@ -652,6 +804,66 @@ export class Solver {
652
804
  reference: new Float64Array(sts[0].nodes[1] - sts[0].nodes[0] + 1),
653
805
  });
654
806
  }
807
+ // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
808
+ // contact, port or reaction) holds its level only through its faces' fluxes, which can be
809
+ // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
810
+ // conducts): eliminated, the level is lost to round-off, and Newton settles at a wrong one with
811
+ // nothing to show for it. In steady solves the piece's balance summed over its nodes, where
812
+ // the internal fluxes cancel exactly and the faces' alone are left, replaces its first node's
813
+ // (see _applyIslands), once the plain solves converge (see newton).
814
+ this.islands = [];
815
+ this.pinnedIslands = [];
816
+ this.islandsOn = false; // (newton() turns them on, see there)
817
+ this.pins = [];
818
+ const used = new Set(this.constraints.map((cs) => cs.row));
819
+ for (const st of this.stretches) {
820
+ const i = st.species, [s0, s1] = st.regions;
821
+ if (!st.mobile || conductor(st)) continue;
822
+ for (let q0 = s0, q1 = s0; q1 <= s1; q1++) {
823
+ const p0 = q0;
824
+ q0 = q1 + 1;
825
+ if (p0 === s0 && q1 === s1) continue; // (uncut)
826
+ if ((p0 === s0 && st.leftOpen) || (q1 === s1 && st.rightOpen)) continue;
827
+ const inside = (q) => q >= p0 && q <= q1;
828
+ 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;
829
+ let reactive = false;
830
+ for (let q = p0; q <= q1; q++) {
831
+ for (const rx of model.reactions) if (rx.kf[regions[q].material] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
832
+ }
833
+ for (let f = Math.max(0, p0 - 1); f <= Math.min(model.interfaces.length - 1, q1); f++) {
834
+ for (const rx of model.interfaces[f].reactions) if (rx.part.some((p) => p.i === i && inside(f + p.side))) reactive = true;
835
+ }
836
+ const row = this.blockOfNode[grid.regionStart[p0]] * this.M + 1 + i;
837
+ if (reactive || used.has(row)) continue;
838
+ used.add(row);
839
+ // The faces' fluxes into the piece: −A_f u at its left face, +A_f u at its right. Across
840
+ // a face that holds μ̄ level, u is set only by the edge balances, and eliminated, it can
841
+ // come out of the piece's own (G Δη, its two η all but equal: a neighbour 1e20 times
842
+ // less conductive, and the piece's level was seen through one face of two, and Newton
843
+ // swung it back and forth across both). There the outside edge node's balance is added:
844
+ // u cancels, and the outside's first segment carries the flux instead.
845
+ const faces = [], outside = [];
846
+ if (p0 > s0) faces.push([p0 - 1, -1, grid.regionEnd[p0 - 1]]);
847
+ if (q1 < s1) faces.push([q1, 1, grid.regionStart[q1 + 1]]);
848
+ for (const [f, , g] of faces) {
849
+ if (model.interfaces[f].links[i].type !== 'equilibrium' || g === 0 || g === this.nNodes - 1) continue;
850
+ if (model.ports.some((port) => port.nodes.includes(g))) continue;
851
+ outside.push(this.blockOfNode[g] * this.M + 1 + i);
852
+ }
853
+ this.islands.push({
854
+ stretch: st,
855
+ row,
856
+ flux: faces.map(([f]) => this.blockOfFace[f] * this.M + 1 + i),
857
+ weight: faces.map(([f, sg]) => sg * grid.area[grid.regionEnd[f]]),
858
+ outside,
859
+ idx: new Int32Array(faces.length + 3 * this.M * outside.length),
860
+ w: new Float64Array(faces.length + 3 * this.M * outside.length),
861
+ len: 0,
862
+ res: 0,
863
+ });
864
+ }
865
+ }
866
+ for (const isl of this.islands) isl.outside = isl.outside.filter((o) => !used.has(o));
655
867
  this.constrained = false;
656
868
  }
657
869
 
@@ -668,6 +880,71 @@ export class Solver {
668
880
  }
669
881
  }
670
882
 
883
+ // The islands' summed balances (steady solves only): Σ A_f u over the faces into each, its
884
+ // first node's row pinned in the factorised matrix and the sum bordered (_solveBordered) like a
885
+ // conserved amount's. The pieces of a stretch held flat by its contact are left as they are.
886
+ _applyIslands() {
887
+ const { u, res } = this, R = this.rix, out = [];
888
+ for (const isl of this.islands) {
889
+ if (this.flattening && this.flatStretches.includes(isl.stretch)) continue;
890
+ const b0 = Math.floor(isl.row / this.M), r0 = isl.row % this.M;
891
+ if (this.loc[isl.row] < 0) continue;
892
+ // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
893
+ // exactly where they meet), then the nonzeros kept.
894
+ const acc = this.islandRow ?? (this.islandRow = new Float64Array(this.sys.size + 1));
895
+ const cols = [];
896
+ const add = (col, v) => {
897
+ if (acc[col] === 0) cols.push(col);
898
+ acc[col] += v;
899
+ };
900
+ let t = 0;
901
+ isl.flux.forEach((o, j) => {
902
+ if (this.loc[o] < 0) return;
903
+ t += isl.weight[j] * u[o];
904
+ add(R[o], isl.weight[j]);
905
+ });
906
+ for (const o of isl.outside) {
907
+ if (this.loc[o] < 0) continue;
908
+ t += res[R[o]];
909
+ this._rowInto(o, add);
910
+ }
911
+ let len = 0;
912
+ for (const col of cols) {
913
+ if (col < this.sys.size && acc[col] !== 0) {
914
+ isl.idx[len] = col;
915
+ isl.w[len++] = acc[col];
916
+ }
917
+ acc[col] = 0;
918
+ }
919
+ isl.len = len;
920
+ isl.res = t;
921
+ this._replaceRow(b0, r0);
922
+ for (const B of this.termB) B[R[isl.row]] = 0;
923
+ this._j(b0, r0, b0, r0, 1);
924
+ res[R[isl.row]] = 0;
925
+ out.push(isl);
926
+ }
927
+ return out;
928
+ }
929
+
930
+ // Row o's Jacobian entries, as add(compact column, value): the assembled ones, and the dilute
931
+ // kernels' kept aside in difference form (see _assembleDifference) when they are.
932
+ _rowInto(o, add) {
933
+ const { sys, M, nB } = this, b = Math.floor(o / M), l = this.loc[o];
934
+ const { A, B, C, sizes, offA, offB, offC, offX } = sys, m = sizes[b];
935
+ 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]);
936
+ 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]);
937
+ 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]);
938
+ if (this.lin) {
939
+ const into = this.linRow ?? (this.linRow = new Float64Array(sys.size + 1));
940
+ this.lin.capture(this.rix[o], 1, 0, into);
941
+ for (let k = 0; k < sys.size; k++) {
942
+ if (into[k] !== 0) add(k, into[k]);
943
+ into[k] = 0;
944
+ }
945
+ }
946
+ }
947
+
671
948
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
672
949
  _applyLocalConstraints() {
673
950
  const { n, M, c, res, dA: d } = this;
@@ -815,7 +1092,8 @@ export class Solver {
815
1092
 
816
1093
  // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
817
1094
  // 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
1095
+ // the rows pinned in T: the spectators' conserved amounts (see _applyConstraints) and the
1096
+ // islands' summed balances (see _applyIslands). By low-rank
819
1097
  // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
820
1098
  // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
821
1099
  // system for the μ (pins) and δV (terminals):
@@ -882,6 +1160,13 @@ export class Solver {
882
1160
  }
883
1161
 
884
1162
 
1163
+ // An electrode surface at its starting coverages at node g: η = μ°/RT + ln(θ/θ₀).
1164
+ _surfaceStart(g) {
1165
+ const { n, M, u, model } = this, surf = model.ports[this.surfPort[g]].surface, b = this.blockOfNode[g];
1166
+ const bare = 1 - surf.reduce((t, sp) => t + sp.theta0, 0);
1167
+ surf.forEach((sp, q) => (u[b * M + 1 + n + q] = sp.mu0 / model.RT + Math.log(sp.theta0 / bare)));
1168
+ }
1169
+
885
1170
  /** Amount (mol per unit area) of surface column j's species, Γ Σ v·a·θ, in the current state. */
886
1171
  surfaceAmount(j) {
887
1172
  const [k, q] = this.surfCols[j], port = this.model.ports[k], vol = this.model.grid.vol;
@@ -1129,13 +1414,7 @@ export class Solver {
1129
1414
  const shift = level(t.side) - level(this.terms[held].side);
1130
1415
  if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1131
1416
  }
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
- }
1417
+ for (let g = 0; g < this.nNodes; g++) if (this.surfPort[g] >= 0) this._surfaceStart(g);
1139
1418
  this.computeConcentrations();
1140
1419
  // A floating capacitance starts uncharged (σ = 0 at the window's mean φ), as the starting
1141
1420
  // composition, neutral without it, assumes; with reactions too, its double layer then charges
@@ -1152,6 +1431,11 @@ export class Solver {
1152
1431
  });
1153
1432
  if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1154
1433
  }
1434
+ // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
1435
+ for (const gt of this.gateList) {
1436
+ const { gate, o } = gt, V = this._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
1437
+ u[o] = a + b > 0 ? a / (a + b) : 0;
1438
+ }
1155
1439
  // A floating electrode spread through a port (without a double layer) starts where its
1156
1440
  // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
1157
1441
  // the start's composition, not level with a held terminal, which can be volts away and pass
@@ -1360,6 +1644,8 @@ export class Solver {
1360
1644
  const { grid, materials, regions } = model;
1361
1645
  sys.clear();
1362
1646
  res.fill(0);
1647
+ this.dtNow = dt;
1648
+ if (this.lin) this.lin.reset(1 / dt);
1363
1649
  this.computeConcentrations();
1364
1650
 
1365
1651
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -1391,8 +1677,13 @@ export class Solver {
1391
1677
  // Terminals: ports (after every other term at their nodes, so a held level can read its
1392
1678
  // flux), then contacts, then the circuit rows of the floating ones.
1393
1679
  this._terminals(dt);
1680
+ if (this.flattening && dt === Infinity) this._flatRows();
1681
+ if (this.flattening && this.constrained && dt === Infinity) this._levelRows();
1394
1682
  if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1395
1683
  if (this.constrained && dt === Infinity && this.localConstraints.length > 0) this._applyLocalConstraints();
1684
+ // Rows kept aside for _solveBordered: the conserved amounts' and the islands'.
1685
+ this.pinnedIslands = this.constrained && dt === Infinity && this.islandsOn ? this._applyIslands() : [];
1686
+ this.pins = this.constrained && dt === Infinity ? [...this.constraints, ...this.pinnedIslands] : [];
1396
1687
  this.transformed = this.combining && dt !== Infinity;
1397
1688
  if (this.transformed) this._chargeRows(dt);
1398
1689
  }
@@ -1412,7 +1703,7 @@ export class Solver {
1412
1703
  // charge in the Gauss row, written straight into the diagonal blocks by local index. A
1413
1704
  // dielectric is the case with no species.
1414
1705
  _nodesDilute(g0, g1, dt) {
1415
- const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode } = this, R = this.rix, F = FARADAY;
1706
+ const { n, M, res, c, cOld, z, sys, loc, present, rhoFixed, blockOfNode, lin } = this, R = this.rix, F = FARADAY;
1416
1707
  const JB = sys.B, sizes = sys.sizes, offB = sys.offB, vol = this.model.grid.vol;
1417
1708
  for (let g = g0; g <= g1; g++) {
1418
1709
  const b = blockOfNode[g], v = vol[g];
@@ -1423,22 +1714,27 @@ export class Solver {
1423
1714
  continue;
1424
1715
  }
1425
1716
  let q = rhoFixed[g], dq = 0;
1717
+ const late = this.combining && this.lateStorage[g] === 1 && dt !== Infinity; // (the ions' storage: in _contact)
1426
1718
  for (let i = 0; i < n; i++) {
1427
1719
  const k = g * n + i;
1428
1720
  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;
1721
+ const ck = c[k], l = loc[lb + 1 + i], stores = !(late && z[i] !== 0);
1722
+ if (stores) res[R[lb + 1 + i]] += (v * (ck - cOld[k])) / dt;
1432
1723
  q += F * z[i] * ck;
1433
1724
  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;
1725
+ if (lin) {
1726
+ lin.node(R[lb + 1 + i], R[lb], z[i], stores ? v * ck : 0, -v * F * z[i] * ck);
1727
+ continue;
1437
1728
  }
1729
+ if (stores) {
1730
+ JB[oB + l * m + l] += (v * ck) / dt;
1731
+ if (p >= 0 && z[i] !== 0) JB[oB + l * m + p] += (-v * z[i] * ck) / dt;
1732
+ }
1733
+ if (p >= 0 && z[i] !== 0) JB[oB + p * m + l] += -v * F * z[i] * ck;
1438
1734
  }
1439
1735
  if (p >= 0) {
1440
1736
  res[R[lb]] -= v * q;
1441
- JB[oB + p * m + p] += v * dq;
1737
+ if (!lin) JB[oB + p * m + p] += v * dq;
1442
1738
  }
1443
1739
  }
1444
1740
  }
@@ -1500,7 +1796,7 @@ export class Solver {
1500
1796
  // A start-of-step net charge (round-off in a solved state) isn't carried over: the neutrality
1501
1797
  // row holds the new state neutral.
1502
1798
  _chargeRows(dt) {
1503
- const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY;
1799
+ const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY, live = this._liveTerms();
1504
1800
  const { A, B, C, sizes, offA, offB, offC } = sys, vol = this.model.grid.vol, last = this.nB - 1;
1505
1801
  for (const g of this.chargeNodes) {
1506
1802
  const b = this.blockOfNode[g], lb = b * M, m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < last ? sizes[b + 1] : 0;
@@ -1524,7 +1820,8 @@ export class Solver {
1524
1820
  for (let q = 0, o = offB[b] + lk * m, s = offB[b] + li * m; q < m; q++) B[o + q] += w * B[s + q];
1525
1821
  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
1822
  res[rk] += w * res[ri];
1527
- for (let t = 0; t < termB.length; t++) termB[t][rk] += w * termB[t][ri];
1823
+ for (const t of live) termB[t][rk] += w * termB[t][ri];
1824
+ if (this.lin) this.lin.combine(rk, ri, w);
1528
1825
  }
1529
1826
  // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
1530
1827
  // unknowns appear (its own rows, its neighbours', a terminal's current).
@@ -1534,7 +1831,7 @@ export class Solver {
1534
1831
  for (let r = 0, o = offB[b]; r < m; r++) B[o + r * m + p] += zi * B[o + r * m + li];
1535
1832
  if (b > 0) for (let r = 0, mr = mp, o = offC[b - 1]; r < mr; r++) C[o + r * m + p] += zi * C[o + r * m + li];
1536
1833
  if (b < last) for (let r = 0, mr = mn, o = offA[b + 1]; r < mr; r++) A[o + r * m + p] += zi * A[o + r * m + li];
1537
- for (let t = 0; t < termC.length; t++) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1834
+ for (const t of live) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1538
1835
  }
1539
1836
  // Storage on the other charged balances, and neutrality: no φ̂' terms, exactly.
1540
1837
  const v = vol[g], oB = offB[b];
@@ -1651,6 +1948,7 @@ export class Solver {
1651
1948
  const mL = sys.sizes[bL], mR = sys.sizes[bR], pL = loc[bL * M], pR = loc[bR * M];
1652
1949
  res[R[bL * M]] += D;
1653
1950
  res[R[bR * M]] -= D;
1951
+ if (this.lin) return this.lin.displacement(R[bL * M], R[bR * M], k);
1654
1952
  sys.B[sys.offB[bL] + pL * mL + pL] += k;
1655
1953
  sys.C[sys.offC[bL] + pL * mR + pR] -= k;
1656
1954
  sys.A[sys.offA[bR] + pR * mL + pL] -= k;
@@ -1664,7 +1962,7 @@ export class Solver {
1664
1962
  // fluxes (e.g. majority carriers carrying a small current) don't vanish in the cancellation of
1665
1963
  // two huge drift and diffusion terms.
1666
1964
  _segmentsDilute(g0, g1, region, mat, vel) {
1667
- const { n, M, u, uLo, res, c, z, sys, loc } = this, R = this.rix, F = FARADAY;
1965
+ const { n, M, u, uLo, res, c, z, sys, loc, lin } = this, R = this.rix;
1668
1966
  const { A: JA, B: JB, C: JC, sizes, offA, offB, offC } = sys;
1669
1967
  const { segLength, segArea } = this.model.grid;
1670
1968
  for (let s = g0; s < g1; s++) {
@@ -1689,6 +1987,10 @@ export class Solver {
1689
1987
  const dNdPhiR = zi * dNdd;
1690
1988
  res[R[bL * M + r]] += N;
1691
1989
  res[R[bR * M + r]] -= N;
1990
+ if (lin) {
1991
+ lin.segment(R[bL * M + r], R[bR * M + r], R[bL * M], R[bR * M], zi, -gBc * E, dNdEtaR, zi * dNdd);
1992
+ continue;
1993
+ }
1692
1994
  // Row r of block bL (couplings to itself in B, to bR in C) and of bR (to bL in A).
1693
1995
  const lL = loc[bL * M + r], lR = loc[bR * M + r];
1694
1996
  const oBL = offB[bL] + lL * mL, oCL = offC[bL] + lL * mR, oAR = offA[bR] + lR * mL, oBR = offB[bR] + lR * mR;
@@ -1778,14 +2080,21 @@ export class Solver {
1778
2080
  // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
1779
2081
  // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
1780
2082
  // and it's exactly zero where μ̄ is level.
1781
- const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], P = itf.links[i].P;
2083
+ const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], link = itf.links[i];
1782
2084
  const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
1783
2085
  const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
1784
2086
  const E = Math.expm1(-deta); // η_R − η_L
2087
+ // Gated: P × Π x^p, each x taken in [0, 1] (Newton's iterates can stray past its ends).
2088
+ const gated = link.gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
2089
+ const P = link.P * (gated.length ? this._open(gated, gx) : 1);
1785
2090
  const gBc = P * bernoulli(d) * this.c[kl];
1786
2091
  const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
1787
2092
  res[R[o]] = u[o] + gBc * E;
1788
2093
  this._j(bf, r, bf, r, 1);
2094
+ for (const [q, p] of gated) {
2095
+ const dO = this._dOpen(gated, q, p, gx);
2096
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, link.P * bernoulli(d) * this.c[kl] * E * dO);
2097
+ }
1789
2098
  this._j(bf, r, bL, r, -gBc);
1790
2099
  this._j(bf, r, bR, r, gBc * (E + 1));
1791
2100
  if (zi !== 0) {
@@ -1793,12 +2102,18 @@ export class Solver {
1793
2102
  this._j(bf, r, bR, 0, -zi * dNdd);
1794
2103
  }
1795
2104
  } else {
1796
- // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
1797
- const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
2105
+ // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F); gated,
2106
+ // G times Π x^p
2107
+ const gated = itf.links[i].gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
2108
+ const gG0 = (itf.links[i].G * VT) / (z[i] * z[i] * F), gG = gG0 * (gated.length ? this._open(gated, gx) : 1);
1798
2109
  res[R[o]] = u[o] - gG * deta;
1799
2110
  this._j(bf, r, bf, r, 1);
1800
2111
  this._j(bf, r, bL, r, -gG);
1801
2112
  this._j(bf, r, bR, r, gG);
2113
+ for (const [q, p] of gated) {
2114
+ const dO = this._dOpen(gated, q, p, gx);
2115
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, -gG0 * deta * dO);
2116
+ }
1802
2117
  }
1803
2118
  res[R[bL * M + r]] += Af * u[o];
1804
2119
  this._j(bL, r, bf, r, Af);
@@ -1806,6 +2121,46 @@ export class Solver {
1806
2121
  this._j(bR, r, bf, r, -Af);
1807
2122
  }
1808
2123
  itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
2124
+ if (itf.gates.length > 0) this._faceGates(f, bf, bL, bR);
2125
+ }
2126
+
2127
+ // Full index of face f's gate q (its fraction open).
2128
+ _gateSlot(f, q) {
2129
+ return this.blockOfFace[f] * this.M + 1 + this.n + this.model.interfaces[f].reactions.length + q;
2130
+ }
2131
+
2132
+ // The voltage a gate follows: across its face, or across its port's capacitance at its node.
2133
+ _gateVoltage(gt) {
2134
+ return gt.port === undefined ? this._faceVoltage(gt.f) : this._portVoltage(gt.port, gt.g);
2135
+ }
2136
+
2137
+ // The voltage across port k's capacitance at node g, φ − (V − zeroCharge) (V): a membrane's,
2138
+ // inside (the region) minus outside.
2139
+ _portVoltage(k, g) {
2140
+ const { M, u, uLo, VT } = this, b = this.blockOfNode[g], port = this.model.ports[k];
2141
+ return VT * (u[b * M] + uLo[b * M]) - (this.termV[2 + k] - port.capacitance.zeroCharge);
2142
+ }
2143
+
2144
+ // The voltage across face f, φ_right − φ_left (V).
2145
+ _faceVoltage(f) {
2146
+ const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
2147
+ return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]));
2148
+ }
2149
+
2150
+ // Face f's gates, each a row (x − x_old)/dt = α(1 − x) − βx in the voltage across the face.
2151
+ _faceGates(f, bf, bL, bR) {
2152
+ const { res, VT } = this, R = this.rix, itf = this.model.interfaces[f], dt = this.dtNow;
2153
+ const V = this._faceVoltage(f), k0 = 1 + this.n + itf.reactions.length;
2154
+ itf.gates.forEach((gate, q) => {
2155
+ const o = this._gateSlot(f, q), x = this.u[o];
2156
+ const [a, da] = gateRate(gate.alpha, V), [b, db] = gateRate(gate.beta, V);
2157
+ const store = Number.isFinite(dt) ? 1 / dt : 0, j = this.gateIndex[f] + q;
2158
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - b * x);
2159
+ this._j(bf, k0 + q, bf, k0 + q, store + a + b);
2160
+ const dV = -(da * (1 - x) - db * x) * VT; // per unit φ̂_R, and minus that per unit φ̂_L
2161
+ this._j(bf, k0 + q, bR, 0, dV);
2162
+ this._j(bf, k0 + q, bL, 0, -dV);
2163
+ });
1809
2164
  }
1810
2165
 
1811
2166
  // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
@@ -2087,16 +2442,26 @@ export class Solver {
2087
2442
  }
2088
2443
  }
2089
2444
 
2445
+ // The terminals whose ∂res/∂V (termB) and ∂I/∂x (termC) are kept, exactly: the floating ones,
2446
+ // which the Newton solve borders on, or all of them (allTerminals: for the impedance, which reads
2447
+ // its terminal's). A held terminal's are otherwise left as they fall (written but not kept
2448
+ // consistent), which spares a device with dozens of ports (an axon's nodes of Ranvier) the cost
2449
+ // of carrying each one's two dense vectors through every assembly.
2450
+ _liveTerms() {
2451
+ if (this.allTerminals) return this._allTerms ?? (this._allTerms = this.terms.map((_, k) => k));
2452
+ return this.floating;
2453
+ }
2454
+
2090
2455
  // Every terminal's terms: its voltage held or floating, how the residual depends on it
2091
2456
  // (termB), its current into the device (termI) and how that depends on the state (termC).
2092
2457
  _terminals(dt) {
2093
2458
  this._refreshSources();
2094
- for (let k = 0; k < this.terms.length; k++) {
2459
+ for (const k of this._liveTerms()) {
2095
2460
  this.termB[k].fill(0);
2096
2461
  this.termC[k].fill(0);
2097
- this.termDI[k] = 0;
2098
- this.termI[k] = 0;
2099
2462
  }
2463
+ this.termDI.fill(0);
2464
+ this.termI.fill(0);
2100
2465
  this.qg.fill(0);
2101
2466
  this.qgSlope.fill(0);
2102
2467
  this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
@@ -2121,6 +2486,7 @@ export class Solver {
2121
2486
  _captureRow(b, s, w, into) {
2122
2487
  const l = this.loc[b * this.M + s];
2123
2488
  if (l < 0 || w === 0) return;
2489
+ if (this.lin) this.lin.capture(this.rix[b * this.M + s], w, this.lin.rate, into);
2124
2490
  const { sys, nB } = this, { sizes, offA, offB, offC, offX } = sys, m = sizes[b];
2125
2491
  for (const [X, nb, off] of [['A', b - 1, offA], ['B', b, offB], ['C', b + 1, offC]]) {
2126
2492
  if (nb < 0 || nb >= nB) continue;
@@ -2158,8 +2524,11 @@ export class Solver {
2158
2524
  B[R[o]] += -z[i] / VT;
2159
2525
  continue;
2160
2526
  }
2161
- // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η)
2162
- const kk = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2527
+ // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η). Gated, G times
2528
+ // Π x^p, the gates at this node (each taken in [0, 1]).
2529
+ const kk0 = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2530
+ const gated = link.gates ?? [], q0 = 1 + n + port.surface.length;
2531
+ const open = gated.length ? this._open(gated, (q) => u[b * M + q0 + q]) : 1, kk = kk0 * open;
2163
2532
  res[R[o]] -= v * kk * deta;
2164
2533
  this._j(b, r, b, r, v * kk);
2165
2534
  flux[i] += v * kk * deta;
@@ -2168,6 +2537,12 @@ export class Solver {
2168
2537
  C[R[o]] += -zF * v * kk;
2169
2538
  this.termDI[k] += (zF * v * kk * z[i]) / VT;
2170
2539
  }
2540
+ for (const [q, p] of gated) {
2541
+ const d = this._dOpen(gated, q, p, (qq) => u[b * M + q0 + qq]);
2542
+ if (d === 0) continue;
2543
+ this._j(b, r, b, q0 + q, -v * kk0 * deta * d);
2544
+ if (z[i] !== 0) C[R[b * M + q0 + q]] += zF * v * kk0 * deta * d;
2545
+ }
2171
2546
  }
2172
2547
  }
2173
2548
  if (holds) {
@@ -2176,6 +2551,7 @@ export class Solver {
2176
2551
  }
2177
2552
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2178
2553
  if (port.capacitance) this._portCapacitance(port, k, dt);
2554
+ if (port.gates.length > 0) this._portGates(port, k, dt);
2179
2555
  if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2180
2556
  }
2181
2557
 
@@ -2244,6 +2620,38 @@ export class Solver {
2244
2620
  // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2245
2621
  // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2246
2622
  // contact sets is left to the contact.
2623
+ // A membrane port's gates, at each node of its window: (x − x_old)/dt = α(1 − x) − βx in the
2624
+ // voltage across its capacitance there, φ − (V − zeroCharge).
2625
+ _portGates(port, k, dt) {
2626
+ const { n, res, VT } = this, R = this.rix, B = this.termB[k], store = Number.isFinite(dt) ? 1 / dt : 0, q0 = 1 + n + port.surface.length;
2627
+ port.nodes.forEach((g, w) => {
2628
+ const b = this.blockOfNode[g], V = this._portVoltage(k - 2, g);
2629
+ port.gates.forEach((gate, q) => {
2630
+ const o = b * this.M + q0 + q, x = this.u[o], j = this.portGateIndex[k - 2] + w * port.gates.length + q;
2631
+ const [a, da] = gateRate(gate.alpha, V), [bb, db] = gateRate(gate.beta, V);
2632
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - bb * x);
2633
+ this._j(b, q0 + q, b, q0 + q, store + a + bb);
2634
+ const s = -(da * (1 - x) - db * x); // ∂/∂V_m, with V_m = V_T φ̂ − V + zeroCharge
2635
+ this._j(b, q0 + q, b, 0, s * VT);
2636
+ B[R[o]] += -s;
2637
+ });
2638
+ });
2639
+ }
2640
+
2641
+ // Π x^p over a link's gates (each x taken in [0, 1]), and its derivative in gate q.
2642
+ _open(gated, x) {
2643
+ let open = 1;
2644
+ for (const [q, p] of gated) open *= powi(Math.min(1, Math.max(0, x(q))), p);
2645
+ return open;
2646
+ }
2647
+ _dOpen(gated, q, p, x) {
2648
+ const xq = x(q);
2649
+ if (!(xq > 0 && xq < 1)) return 0;
2650
+ let others = 1;
2651
+ for (const [q2, p2] of gated) if (q2 !== q) others *= powi(Math.min(1, Math.max(0, x(q2))), p2);
2652
+ return p * powi(xq, p - 1) * others;
2653
+ }
2654
+
2247
2655
  _portCapacitance(port, k, dt) {
2248
2656
  const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2249
2657
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
@@ -2323,6 +2731,19 @@ export class Solver {
2323
2731
  this._captureRow(b, 1 + i, z[i] * F, C);
2324
2732
  }
2325
2733
  }
2734
+ // The ions' storage, left out until now (see lateStorage): in each balance, and in what
2735
+ // each species brings in, but not in the current.
2736
+ if (this.combining && dyn && this.lateStorage[g] === 1) {
2737
+ const v = model.grid.vol[g], { cOld } = this;
2738
+ for (let i = 0; i < n; i++) {
2739
+ if (z[i] === 0 || this.loc[b * M + 1 + i] < 0) continue;
2740
+ const ci = c[g * n + i], st = (v * (ci - cOld[g * n + i])) / dt;
2741
+ res[R[b * M + 1 + i]] += st;
2742
+ flux[i] += sgn * st;
2743
+ this._j(b, 1 + i, b, 1 + i, (v * ci) / dt);
2744
+ this._j(b, 1 + i, b, 0, (-v * z[i] * ci) / dt);
2745
+ }
2746
+ }
2326
2747
 
2327
2748
  // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2328
2749
  // links (neutral species): N_in = k (μ_out − μ)/RT.
@@ -2372,7 +2793,7 @@ export class Solver {
2372
2793
  // The Poisson residual is the outside's charge, D_in.
2373
2794
  const Din = res[R[b * M]];
2374
2795
  this.contactD[side] = sgn * Din;
2375
- if (dyn) {
2796
+ if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2376
2797
  I += (Din - Dstart) / dt;
2377
2798
  this._captureRow(b, 0, 1 / dt, C);
2378
2799
  }
@@ -2405,10 +2826,59 @@ export class Solver {
2405
2826
 
2406
2827
 
2407
2828
 
2829
+ // Steady solves: each flat stretch's level held at its contact's throughout (see
2830
+ // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
2831
+ _flatRows() {
2832
+ const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
2833
+ for (const st of this.flatStretches) {
2834
+ const i = st.species, side = st.leftOpen ? 'left' : 'right', k = side === 'left' ? 0 : 1;
2835
+ const link = model.contacts[side].species[i];
2836
+ const level = z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT;
2837
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
2838
+ const b = this.blockOfNode[g], o = b * M + 1 + i;
2839
+ if (this.loc[o] < 0) continue;
2840
+ this._replaceRow(b, 1 + i);
2841
+ for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
2842
+ this._j(b, 1 + i, b, 1 + i, 1);
2843
+ res[R[o]] = u[o] + uLo[o] - level;
2844
+ if (z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
2845
+ }
2846
+ // A face inside the stretch whose link holds the level continuous carries the species' flux
2847
+ // as an unknown that only the edge balances, now replaced, determined: it's zero.
2848
+ for (let f = st.regions[0]; f < st.regions[1]; f++) {
2849
+ if (model.interfaces[f].links[i].type !== 'equilibrium') continue;
2850
+ const b = this.blockOfFace[f], o = b * M + 1 + i;
2851
+ if (this.loc[o] < 0) continue;
2852
+ this._replaceRow(b, 1 + i);
2853
+ for (const B of this.termB) B[R[o]] = 0;
2854
+ this._j(b, 1 + i, b, 1 + i, 1);
2855
+ res[R[o]] = u[o] + uLo[o];
2856
+ }
2857
+ }
2858
+ }
2859
+
2408
2860
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
2861
+ // Steady solves: each level stretch's levels held equal (see levelStretches), node to node in
2862
+ // place of the balances. The first node's row is the amount's (see _applyConstraints).
2863
+ _levelRows() {
2864
+ const { M, u, uLo, res } = this, R = this.rix;
2865
+ for (const st of this.levelStretches) {
2866
+ const i = st.species;
2867
+ for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
2868
+ const b = this.blockOfNode[g], o = b * M + 1 + i, p = o - M;
2869
+ if (this.loc[o] < 0 || this.loc[p] < 0) continue;
2870
+ this._replaceRow(b, 1 + i);
2871
+ for (const B of this.termB) B[R[o]] = 0;
2872
+ this._j(b, 1 + i, b, 1 + i, 1);
2873
+ this._j(b, 1 + i, b - 1, 1 + i, -1);
2874
+ res[R[o]] = u[o] + uLo[o] - (u[p] + uLo[p]);
2875
+ }
2876
+ }
2877
+ }
2409
2878
  _replaceRow(b, rs) {
2410
2879
  const l = this.loc[b * this.M + rs];
2411
2880
  if (l < 0) return;
2881
+ if (this.lin) this.lin.kill(this.rix[b * this.M + rs]);
2412
2882
  const { sys, nB } = this, sz = sys.sizes, m = sz[b];
2413
2883
  sys.B.fill(0, sys.offB[b] + l * m, sys.offB[b] + (l + 1) * m);
2414
2884
  if (b > 0) sys.A.fill(0, sys.offA[b] + l * sz[b - 1], sys.offA[b] + (l + 1) * sz[b - 1]);
@@ -2417,7 +2887,7 @@ export class Solver {
2417
2887
 
2418
2888
  // Scale every row by its largest Jacobian entry (in place, residual too).
2419
2889
  _equilibrate() {
2420
- const { nB, sys, res, termB } = this;
2890
+ const { nB, sys, res, termB } = this, live = this._liveTerms();
2421
2891
  const { A, B, C, sizes, offA, offB, offC, offX } = sys;
2422
2892
  for (let b = 0; b < nB; b++) {
2423
2893
  const m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < nB - 1 ? sizes[b + 1] : 0;
@@ -2427,14 +2897,123 @@ export class Solver {
2427
2897
  for (let k = 0; k < mp; k++) mx = Math.max(mx, Math.abs(A[oa + k]));
2428
2898
  for (let k = 0; k < m; k++) mx = Math.max(mx, Math.abs(B[ob + k]));
2429
2899
  for (let k = 0; k < mn; k++) mx = Math.max(mx, Math.abs(C[oc + k]));
2430
- if (mx === 0) continue;
2900
+ if (mx === 0) {
2901
+ this.rowScale[offX[b] + r] = 1;
2902
+ continue;
2903
+ }
2431
2904
  const s = 1 / mx;
2905
+ this.rowScale[offX[b] + r] = s;
2432
2906
  for (let k = 0; k < mp; k++) A[oa + k] *= s;
2433
2907
  for (let k = 0; k < m; k++) B[ob + k] *= s;
2434
2908
  for (let k = 0; k < mn; k++) C[oc + k] *= s;
2435
2909
  res[offX[b] + r] *= s;
2436
- for (let k = 0; k < termB.length; k++) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
2910
+ for (const k of live) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
2911
+ }
2912
+ }
2913
+ }
2914
+
2915
+ // The Jacobian at dt with the dilute kernels' terms kept aside in difference form (see
2916
+ // DifferenceTerms), the rest assembled into a matrix of its own. The residual and terminal terms
2917
+ // come out as from assemble(dt); the Newton matrix is left as it was.
2918
+ _assembleDifference(dt) {
2919
+ const keep = this.sys;
2920
+ const rest = this.sysRest ?? (this.sysRest = new BlockTridiagonal(keep.n, keep.sizes));
2921
+ const lin = this.diffTerms ?? (this.diffTerms = new DifferenceTerms(keep.size, this.nNodes * this.n, this.nNodes * this.n));
2922
+ this.sys = rest;
2923
+ this.lin = lin;
2924
+ try {
2925
+ this.assemble(dt);
2926
+ } finally {
2927
+ this.sys = keep;
2928
+ this.lin = null;
2929
+ }
2930
+ return { rest, lin };
2931
+ }
2932
+
2933
+ // A Newton solve refined by GMRES: the factorised Jacobian as the preconditioner, and J·x
2934
+ // exact, with the dilute kernels' terms in difference form (see DifferenceTerms), as the
2935
+ // impedance does. The assembled J holds a flux's dependence on η at its two ends as two
2936
+ // entries; where they're huge (an inversion layer's 0.1 nm cells, a conductance ~1e11 against
2937
+ // the layer's own storage and the trickle from the bulk), eliminating them loses the layer's
2938
+ // overall level to round-off, and Newton rattles there. delta and deltaV come in as the plain
2939
+ // solve's and leave refined (in the transformed unknowns, as _solveBordered gives them); false
2940
+ // if refining didn't help, leaving them as they came.
2941
+ _refine(dt, delta, deltaV) {
2942
+ const { res, termRes, termB, termC, termDI, rowScale, floating: fl } = this;
2943
+ const N = this.sys.size, K = fl.length, pins = this.pins, R = this.rix;
2944
+ 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) };
2945
+ const restoreArrays = () => {
2946
+ res.set(save.res);
2947
+ termRes.set(save.termRes);
2948
+ save.termB.forEach((b, k) => termB[k].set(b));
2949
+ save.termC.forEach((c, k) => termC[k].set(c));
2950
+ termDI.set(save.termDI);
2951
+ this.transformed = save.transformed;
2952
+ this.pins.forEach((p, a) => (p.res = save.pinRes[a]));
2953
+ };
2954
+ // The same system, its terms kept apart: the rest, and each floating terminal's (unscaled).
2955
+ const { rest, lin } = this._assembleDifference(dt);
2956
+ const Bk = fl.map((k) => Float64Array.from(termB[k])), Ck = fl.map((k) => Float64Array.from(termC[k])), Dk = fl.map((k) => termDI[k]);
2957
+ restoreArrays();
2958
+ const v = new Float64Array(N + 1), jv = new Float64Array(N);
2959
+ const op = (x) => {
2960
+ for (let j = 0; j < N; j++) v[j] = x[j];
2961
+ v[N] = 0;
2962
+ rest.multiply(v, jv);
2963
+ if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
2964
+ lin.apply(v, jv, 1, 1 / dt);
2965
+ const out = new Float64Array(N + K);
2966
+ for (let j = 0; j < N; j++) {
2967
+ let t = jv[j];
2968
+ for (let a = 0; a < K; a++) t += Bk[a][j] * x[N + a];
2969
+ out[j] = t * rowScale[j];
2970
+ }
2971
+ for (let a = 0; a < K; a++) {
2972
+ let t = Dk[a] * x[N + a];
2973
+ for (let j = 0; j < N; j++) t += Ck[a][j] * x[j];
2974
+ out[N + a] = t;
2975
+ }
2976
+ // A pinned row is its constraint's (a conserved amount, an island's summed balance).
2977
+ for (const p of pins) {
2978
+ let t = 0;
2979
+ for (let j = 0; j < p.len; j++) t += p.w[j] * x[p.idx[j]];
2980
+ out[R[p.row]] = t;
2437
2981
  }
2982
+ return precondition(out);
2983
+ };
2984
+ const rhs = new Float64Array(N + 1), out = new Float64Array(N + 1), outV = new Float64Array(K);
2985
+ const precondition = (r) => {
2986
+ for (let j = 0; j < N; j++) rhs[j] = r[j];
2987
+ fl.forEach((k, a) => (termRes[k] = r[N + a]));
2988
+ for (const p of pins) {
2989
+ p.res = rhs[R[p.row]];
2990
+ rhs[R[p.row]] = 0;
2991
+ }
2992
+ this._solveBordered(rhs, out, outV, pins);
2993
+ const z = new Float64Array(N + K);
2994
+ for (let j = 0; j < N; j++) z[j] = out[j];
2995
+ for (let a = 0; a < K; a++) z[N + a] = outV[a];
2996
+ return z;
2997
+ };
2998
+ try {
2999
+ const b = new Float64Array(N + K);
3000
+ for (let j = 0; j < N; j++) b[j] = save.res[j];
3001
+ fl.forEach((k, a) => (b[N + a] = save.termRes[k]));
3002
+ pins.forEach((p, a) => (b[R[p.row]] = save.pinRes[a]));
3003
+ const pb = precondition(b), x = Float64Array.from(pb);
3004
+ // A lost mode or two, GMRES finds in a couple of iterations. The refined update is kept
3005
+ // where GMRES converged and its correction is under a thermal unit; otherwise newton()
3006
+ // stops refining.
3007
+ const x0 = Float64Array.from(x);
3008
+ const ok = gmresReal(op, pb, x, { m: 8, restarts: 1, tol: 1e-8 });
3009
+ let c = 0;
3010
+ for (let j = 0; j < N + K; j++) c = Math.max(c, Math.abs(x[j] - x0[j]));
3011
+ if (!ok || !(c < 1)) return false;
3012
+ for (let j = 0; j < N; j++) delta[j] = x[j];
3013
+ for (let a = 0; a < K; a++) deltaV[a] = x[N + a];
3014
+ return true;
3015
+ } finally {
3016
+ restoreArrays();
2438
3017
  }
2439
3018
  }
2440
3019
 
@@ -2453,6 +3032,7 @@ export class Solver {
2453
3032
  const k = this.surfPort[g]; // an electrode surface's coverages, as η
2454
3033
  if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
2455
3034
  }
3035
+ for (const gt of this.gateList) mx = Math.max(mx, Math.abs(delta[R[gt.o]])); // a gate's fraction open
2456
3036
  return mx;
2457
3037
  }
2458
3038
 
@@ -2460,16 +3040,55 @@ export class Solver {
2460
3040
  * Newton iteration for one backward-Euler step from the current cOld.
2461
3041
  * @returns {{converged: boolean, iterations: number, history: number[]}}
2462
3042
  */
2463
- newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10 } = {}) {
3043
+ newton(dt, { maxIter = 60, tol = 1e-10, maxStep = 10, islands = false } = {}) {
2464
3044
  const { delta, res } = this;
2465
3045
  const deltaV = this.deltaV ?? (this.deltaV = new Float64Array(this.floating.length));
2466
3046
  const history = [], ownHistory = [];
2467
- // A steady solve that fails: record how nearly singular the system was, and where.
3047
+ // Refined solves (see _refine), once the plain ones stall.
3048
+ let refine = false, refined = 0, noRefine = false;
3049
+ // Islands (see _applyIslands): their summed rows go in only once the plain solves converge.
3050
+ // Far from the solution, Newton exact along a weakly held level takes it by 1e5 thermal
3051
+ // units (a floating base, cold at bias), where the plain solves, which barely see that level,
3052
+ // converge. Converged, an island at the wrong level shows: it takes in a different current
3053
+ // than it passes on, so the terminal currents don't add up to zero. Then the summed rows go
3054
+ // in and Newton carries on from there. Converged again, a current that tiny against the
3055
+ // conduction around it rides on differences (~1e-28 thermal units for 5 fA/m² through a bulk
3056
+ // of 1e4 mol/m³ at 1e-4 m²/s) that the last update leaves known only to its own round-off:
3057
+ // where the currents still don't add up, one more update polishes them. Where the islands'
3058
+ // own system is held by static pivots elsewhere (a GaAs stack's minority carriers, at 1e-17
3059
+ // A/m² of noise), their rows can throw Newton off instead: if it doesn't converge again
3060
+ // within a dozen iterations, the plain solution stands.
3061
+ this.islandsOn = islands;
3062
+ let rounds = 0, kept = null;
3063
+ const islandsOff = (it, result) => {
3064
+ if (dt !== Infinity || !this.constrained || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3065
+ this.islandsOn = true;
3066
+ rounds++;
3067
+ kept = { it, result, u: Float64Array.from(this.u), uLo: Float64Array.from(this.uLo), termV: Float64Array.from(this.termV) };
3068
+ return true;
3069
+ };
3070
+ // A steady solve that fails: record how nearly singular the system was, and where; or, failing
3071
+ // with the islands' rows in, go back to the solution without them.
2468
3072
  const fail = (r) => {
3073
+ if (kept) {
3074
+ this.u.set(kept.u);
3075
+ this.uLo.set(kept.uLo);
3076
+ this.termV.set(kept.termV);
3077
+ this.islandsOn = false;
3078
+ this.computeConcentrations();
3079
+ return { ...kept.result, iterations: r.iterations, history };
3080
+ }
2469
3081
  if (dt === Infinity) this._noteConditioning();
2470
3082
  return r;
2471
3083
  };
2472
3084
  for (let it = 1; it <= maxIter; it++) {
3085
+ if (kept && it - kept.it > 12) return fail({ converged: false, iterations: it - 1, history });
3086
+ if (kept && it === kept.it + 2) {
3087
+ // Refined from the islands' second update (their first moves the level, perhaps by
3088
+ // more than a refinement may), since the plain solves have shown a mode they lose.
3089
+ refine = true;
3090
+ noRefine = false;
3091
+ }
2473
3092
  try {
2474
3093
  this.assemble(dt);
2475
3094
  } catch (err) {
@@ -2486,9 +3105,13 @@ export class Solver {
2486
3105
  return fail({ converged: false, iterations: it, history, error: err.message });
2487
3106
  }
2488
3107
  try {
2489
- const pins = this.constrained && dt === Infinity ? this.constraints : [];
3108
+ const pins = this.pins;
2490
3109
  deltaV.fill(0);
2491
3110
  this._solveBordered(res, delta, deltaV, pins);
3111
+ if (refine && !this._refine(dt, delta, deltaV)) {
3112
+ refine = false; // (GMRES didn't converge, or strayed: the plain solves it is)
3113
+ noRefine = true;
3114
+ }
2492
3115
  if (this.transformed) this._untransform(delta);
2493
3116
  } catch (err) {
2494
3117
  if (!(err instanceof SolverError)) throw err;
@@ -2505,7 +3128,7 @@ export class Solver {
2505
3128
  ownHistory.push(own);
2506
3129
  if (!Number.isFinite(step)) return fail({ converged: false, iterations: it, history, error: 'non-finite update' });
2507
3130
  // Give up early on clear divergence; the caller will take a smaller step instead.
2508
- if (own > 1e4 || (it > 6 && own > 10 * ownHistory[0])) {
3131
+ if ((it > 1 && own > 1e4) || (it > 6 && own > 10 * ownHistory[0])) {
2509
3132
  this.computeConcentrations();
2510
3133
  return fail({ converged: false, iterations: it, history, error: 'diverging' });
2511
3134
  }
@@ -2513,8 +3136,10 @@ export class Solver {
2513
3136
  this._addToState(delta, -alpha);
2514
3137
  this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
2515
3138
  if (alpha === 1 && step < tol) {
3139
+ const done = { converged: true, iterations: it, history, residual: rmax };
3140
+ if (islandsOff(it, done)) continue;
2516
3141
  this.computeConcentrations();
2517
- return { converged: true, iterations: it, history, residual: rmax };
3142
+ return done;
2518
3143
  }
2519
3144
  // Converged as far as round-off allows: the updates are already tiny (below 1e-6 thermal
2520
3145
  // units, ~26 nV) and have stopped shrinking. A badly conditioned system's floor can sit
@@ -2523,15 +3148,35 @@ export class Solver {
2523
3148
  // (Raising this floor lets a weakly held population drift: a MOS capacitor without a
2524
3149
  // channel port then showed a DC leak through its oxide.)
2525
3150
  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) {
3151
+ // Stalled near the solution (not shrinking quadratically), or growing undamped twice
3152
+ // running: the factorised Jacobian may have lost a slow mode to cancellation, and
3153
+ // round-off's floor would accept it wrong, or its noise keep Newton from the solution (a
3154
+ // bipolar stack's floating base, its holes held ~1e14 more weakly than they move within
3155
+ // it). Refine the solves from here.
3156
+ if (refine) refined++;
3157
+ else if (!noRefine && alpha === 1 && it >= 3 && step > 0.25 * p1 && (step < 1e-4 || (step > p1 && p1 > p2))) refine = true;
3158
+ if (alpha === 1 && it >= 4 && (!refine || refined >= 1 || noRefine) && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3159
+ const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3160
+ if (islandsOff(it, done)) continue;
2527
3161
  this.computeConcentrations();
2528
- return { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3162
+ return done;
2529
3163
  }
2530
3164
  }
2531
3165
  this.computeConcentrations();
2532
3166
  return fail({ converged: false, iterations: maxIter, history });
2533
3167
  }
2534
3168
 
3169
+ // How far the terminal currents (as last assembled) are from adding up to zero, relative to the
3170
+ // largest of them: Kirchhoff's law, which a steady state keeps.
3171
+ _kirchhoff() {
3172
+ let sum = 0, mx = 0;
3173
+ for (const I of this.termI) {
3174
+ sum += I;
3175
+ mx = Math.max(mx, Math.abs(I));
3176
+ }
3177
+ return mx > 0 ? Math.abs(sum) / mx : 0;
3178
+ }
3179
+
2535
3180
  // How many digits the last factorisation lost to cancellation, and where (the worst seen since
2536
3181
  // the steady solve began). Only for diagnosing failures: a saturated species, for instance,
2537
3182
  // can lose digits harmlessly.
@@ -2609,6 +3254,7 @@ export class Solver {
2609
3254
  this.termVPrev.set(this.termV);
2610
3255
  this.computeConcentrations();
2611
3256
  const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
3257
+ const gN = Float64Array.from(this.gateList, (gt) => this.u[gt.o] + this.uLo[gt.o]);
2612
3258
  // Contact displacement before the step: as it was at the end of the previous step (under
2613
3259
  // the parameters then), so a gate-voltage change shows up as displacement current.
2614
3260
  if (!this.contactDEnd || !this.portQEnd) {
@@ -2629,6 +3275,7 @@ export class Solver {
2629
3275
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
2630
3276
  for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
2631
3277
  for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
3278
+ for (let k = 0; k < gN.length; k++) this.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
2632
3279
  this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
2633
3280
  this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
2634
3281
  dtEff = dt / a0;
@@ -2636,6 +3283,7 @@ export class Solver {
2636
3283
  this.cOld.set(cN);
2637
3284
  this.thOld.set(thN);
2638
3285
  this.th0Old.set(th0N);
3286
+ this.gateOld.set(gN);
2639
3287
  this.contactDStart = DN;
2640
3288
  this.portQStart = QN;
2641
3289
  }
@@ -2648,12 +3296,26 @@ export class Solver {
2648
3296
  let result;
2649
3297
  try {
2650
3298
  result = this.newton(dtEff, opts);
3299
+ if (!result.converged && dtEff === Infinity && this.constrained && this.islands.length > 0) {
3300
+ // Plain solves that don't converge may be lost along an island's level (two regions
3301
+ // conducting 1e20 times less hold it): once more from the start, with the islands'
3302
+ // summed rows in throughout (see newton).
3303
+ this.u.set(this.uPrev);
3304
+ this.uLo.set(this.uPrevLo);
3305
+ this.termV.set(this.termVPrev);
3306
+ if (opts.guess) {
3307
+ this.u.set(opts.guess);
3308
+ this.uLo.fill(0);
3309
+ }
3310
+ const again = this.newton(dtEff, { ...opts, islands: true });
3311
+ result = { ...again, iterations: result.iterations + again.iterations };
3312
+ }
2651
3313
  } finally {
2652
3314
  this.combining = false;
2653
3315
  }
2654
3316
  result.bdf = bdf;
2655
3317
  if (result.converged) {
2656
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, D: DN, Q: QN });
3318
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
2657
3319
  if (this.history.length > 3) this.history.length = 3;
2658
3320
  this.time = tEnd;
2659
3321
  this.lastDt = dtEff;
@@ -2690,11 +3352,11 @@ export class Solver {
2690
3352
  if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
2691
3353
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2692
3354
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2693
- let grow = 0; // longer first steps tried after a Newton failure (see below)
2694
3355
  // The shortest step worth trying: round-off relative to the time, or the device's fastest
2695
3356
  // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
2696
3357
  const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
2697
3358
  const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
3359
+ const firstErrs = []; // the first step's error at each size tried (see below)
2698
3360
  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
3361
  while (this.time < tEnd) {
2700
3362
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
@@ -2719,6 +3381,7 @@ export class Solver {
2719
3381
  this.landing = atBreak ? tb : clamped ? tEnd : undefined;
2720
3382
  const snap = this._snapshot();
2721
3383
  let err, p, half = null;
3384
+ if (this.history.length > 0) firstErrs.length = 0;
2722
3385
  if (this.history.length === 0) {
2723
3386
  // First step: backward Euler, checked against two half steps (whose result is kept).
2724
3387
  const full = this.step(h);
@@ -2739,17 +3402,7 @@ export class Solver {
2739
3402
  if (err === undefined) {
2740
3403
  this._restore(snap);
2741
3404
  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
- }
3405
+ dt = h / 4;
2753
3406
  if (dt < floor) { failed = true; break; }
2754
3407
  continue;
2755
3408
  }
@@ -2767,14 +3420,20 @@ export class Solver {
2767
3420
  err = this._errorNorm(pred) * p.scale;
2768
3421
  p = p.order;
2769
3422
  }
2770
- if (err > tol) {
3423
+ // A first step whose error doesn't shrink with it: a jump in a level held at a boundary
3424
+ // (a bath's, after a voltage step) starts a profile self-similar in x/√t, so a first step
3425
+ // of any length errs alike, down to the grid's own diffusion time, below Newton's reach.
3426
+ // Three tries in a row, each within a factor 2 of the last, say so: the error is the
3427
+ // jump's, not the step's. Take the step (backward Euler damps it as it should) and grow.
3428
+ if (half && err > tol) firstErrs.push(err);
3429
+ 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]));
3430
+ if (err > tol && !jump) {
2771
3431
  this._restore(snap);
2772
3432
  rejected++;
2773
3433
  dt = h * factor(err, p);
2774
3434
  continue;
2775
3435
  }
2776
3436
  steps++;
2777
- grow = 0;
2778
3437
  // The state the first step started from needn't satisfy the algebraic equations (φ in a
2779
3438
  // strictly neutral region, an interface's unknowns, after a start or a jump), and they
2780
3439
  // jump in the first instant; a predictor through it would see that jump as error on every
@@ -2857,9 +3516,22 @@ export class Solver {
2857
3516
  const fail = (m) => {
2858
3517
  throw new DeviceError(m);
2859
3518
  };
2860
- if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
3519
+ if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }, or { interface, species } / { interface, gate }');
2861
3520
  const plan = probes.map((p, k) => {
2862
3521
  const path = `advance: probes[${k}]`;
3522
+ if (p?.interface !== undefined) {
3523
+ // At a face: a species' flux through it (mol/(m²·s), toward +x), or a gate's fraction open.
3524
+ const f = p.interface, itf = this.model.interfaces[f];
3525
+ if (!(Number.isInteger(f) && itf)) fail(`${path}.interface: no interface ${JSON.stringify(f)} (they're numbered from 0, left to right)`);
3526
+ if (p.gate !== undefined) {
3527
+ const q = itf.gates.findIndex((gt) => gt.name === p.gate);
3528
+ if (q < 0) fail(`${path}.gate: interfaces[${f}] has no gate ${JSON.stringify(p.gate)}${itf.gates.length ? ` (it has ${itf.gates.map((gt) => gt.name).join(', ')})` : ''}`);
3529
+ return { o: this._gateSlot(f, q) };
3530
+ }
3531
+ const i = species.findIndex((sp) => sp.name === p.species);
3532
+ if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p.species)}`);
3533
+ return { o: this.blockOfFace[f] * this.M + 1 + i };
3534
+ }
2863
3535
  const quantity = p?.quantity ?? 'c';
2864
3536
  if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
2865
3537
  const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
@@ -2891,7 +3563,7 @@ export class Solver {
2891
3563
  };
2892
3564
  return {
2893
3565
  out,
2894
- read: () => plan.map((q) => (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1)),
3566
+ read: () => plan.map((q) => (q.o !== undefined ? (this.loc[q.o] < 0 ? 0 : this.u[q.o]) : (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1))),
2895
3567
  push: (values) => values.forEach((v, k) => out[k].push(v)),
2896
3568
  };
2897
3569
  }
@@ -2909,6 +3581,7 @@ export class Solver {
2909
3581
  const k = this.surfPort[g]; // an electrode surface's coverages, as η
2910
3582
  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]));
2911
3583
  }
3584
+ for (const { o } of this.gateList) mx = Math.max(mx, Math.abs(u[o] - ref[o])); // (a fraction: 1e-3 of m³ is 0.3% of P)
2912
3585
  return mx;
2913
3586
  }
2914
3587
 
@@ -2952,6 +3625,7 @@ export class Solver {
2952
3625
  cOld: Float64Array.from(this.cOld),
2953
3626
  thOld: Float64Array.from(this.thOld),
2954
3627
  th0Old: Float64Array.from(this.th0Old),
3628
+ gateOld: Float64Array.from(this.gateOld),
2955
3629
  time: this.time,
2956
3630
  lastDt: this.lastDt,
2957
3631
  atSteady: this.atSteady,
@@ -2974,6 +3648,7 @@ export class Solver {
2974
3648
  this.cOld.set(s.cOld);
2975
3649
  this.thOld.set(s.thOld);
2976
3650
  this.th0Old.set(s.th0Old);
3651
+ this.gateOld.set(s.gateOld);
2977
3652
  this.time = s.time;
2978
3653
  this.lastDt = s.lastDt;
2979
3654
  this.atSteady = s.atSteady;
@@ -3009,7 +3684,17 @@ export class Solver {
3009
3684
  * Z = δV/δI with I into the device (positive real part for a passive device).
3010
3685
  * @param {ArrayLike<number>} frequencies Hz
3011
3686
  */
3012
- impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3687
+ impedance(frequencies, opts) {
3688
+ const was = this.allTerminals;
3689
+ this.allTerminals = true; // (it reads its terminal's ∂res/∂V and ∂I/∂x)
3690
+ try {
3691
+ return this._impedance(frequencies, opts);
3692
+ } finally {
3693
+ this.allTerminals = was;
3694
+ }
3695
+ }
3696
+
3697
+ _impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3013
3698
  const { M, nB, sys, VT } = this;
3014
3699
  const kT = this.terms.findIndex((t) => t.name === terminal);
3015
3700
  if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
@@ -3021,6 +3706,7 @@ export class Solver {
3021
3706
  this.cOld.set(this.c);
3022
3707
  this.thOld.set(this.th);
3023
3708
  this.th0Old.set(this.th0);
3709
+ this.gateList.forEach((gt, j) => (this.gateOld[j] = this.u[gt.o]));
3024
3710
  this.assemble(Infinity);
3025
3711
  this.contactDStart = { ...this.contactD };
3026
3712
  this.portQStart = Float64Array.from(this.portQ);
@@ -3034,52 +3720,17 @@ export class Solver {
3034
3720
  for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
3035
3721
  const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
3036
3722
  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
- };
3723
+ const cuts = this._currentCuts();
3724
+ // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
3725
+ // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
3726
+ // flux to round-off. So the dilute kernels' terms are kept in difference form (see
3727
+ // DifferenceTerms), with the rest from a matrix of their own, steady (Jr) and storage (Sr).
3728
+ this._assembleDifference(Infinity);
3729
+ const Jr = { A: this.sysRest.A.slice(), B: this.sysRest.B.slice(), C: this.sysRest.C.slice() };
3730
+ const { lin } = this._assembleDifference(dts);
3731
+ const Sr = {};
3732
+ for (const X of ['A', 'B', 'C']) Sr[X] = this.sysRest[X].map((v, k) => (v - Jr[X][k]) * dts);
3733
+ this.assemble(Infinity);
3083
3734
 
3084
3735
  const { sizes, offA, offB, offC, offX } = sys;
3085
3736
  const csys = new ComplexBlockTridiagonal(nB, sizes);
@@ -3125,9 +3776,6 @@ export class Solver {
3125
3776
  }
3126
3777
  }
3127
3778
  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
3779
  const sx = (X, vr, out) => {
3132
3780
  for (let blk = 0; blk < nB; blk++) {
3133
3781
  const m = sizes[blk], mp = blk > 0 ? sizes[blk - 1] : 0, mn = blk < nB - 1 ? sizes[blk + 1] : 0;
@@ -3153,25 +3801,35 @@ export class Solver {
3153
3801
  csys.solve(rRes, iRes, outR, outI);
3154
3802
  return [outR, outI];
3155
3803
  };
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);
3804
+ // (J + iωS)·v, exactly (see DifferenceTerms), into (ar, ai).
3805
+ const jv = (vr, vi, ar, ai) => {
3806
+ sx(Jr, vr, jr);
3807
+ sx(Jr, vi, ji);
3808
+ sx(Sr, vi, si);
3809
+ sx(Sr, vr, sr);
3162
3810
  for (let j = 0; j < Nc; j++) {
3163
3811
  ar[j] = jr[j] - w * si[j];
3164
3812
  ai[j] = ji[j] + w * sr[j];
3165
3813
  }
3814
+ lin.apply(vr, ar, 1, 0);
3815
+ lin.apply(vi, ar, 0, -w);
3816
+ lin.apply(vi, ai, 1, 0);
3817
+ lin.apply(vr, ai, 0, w);
3818
+ };
3819
+ const op = ([vr, vi]) => {
3820
+ const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3821
+ jv(vr, vi, ar, ai);
3166
3822
  return precondition(ar, ai);
3167
3823
  };
3168
3824
  const solve = (br, bi, outr, outi, k, sigma) => {
3169
3825
  const rhs = k >= 0 ? precondition(Bt[k].map((v) => -sigma * v), zero) : [new Float64Array(Nc), new Float64Array(Nc)];
3170
3826
  outr.set(rhs[0]);
3171
3827
  outi.set(rhs[1]);
3172
- gmres(op, rhs, [outr, outi]);
3173
- restore();
3828
+ // (GMRES keeps its best iterate, which is never worse than the factorised solve.)
3829
+ const { residual } = gmres(op, rhs, [outr, outi]);
3830
+ accuracy = Math.max(accuracy, residual);
3174
3831
  };
3832
+ let accuracy = 1e-14; // (relative, of the solves: the readouts' error is weighed with it)
3175
3833
  // y: the response with the floating terminals held; X_k: to a unit δV_k.
3176
3834
  const heldT = dT.kind === 'V';
3177
3835
  for (let j = 0; j < Nc; j++) {
@@ -3211,12 +3869,93 @@ export class Solver {
3211
3869
  xr[this.fullOf[j]] = re;
3212
3870
  xi[this.fullOf[j]] = im;
3213
3871
  }
3872
+ // The current into terminal kT per volt, (C + iωC′)·δx + (∂I/∂V)(1 + iω′), with its error.
3873
+ // At the contact it's a sum whose terms can be far larger than itself (in a junction's
3874
+ // neutral ends, a conductance times a δη that cancels its neighbours' to 1e-17), and its
3875
+ // round-off reads as a constant conductance. Between the two contacts of a device without
3876
+ // ports, the total current (conduction and displacement) is the same through every cut, so
3877
+ // it's also read across each segment, and the reading with the least error taken. Each
3878
+ // reading's error is its terms' round-off and the solves' error in δx (to `accuracy` of its
3879
+ // largest entry) through its coefficients: a contact's or a cut's flux is a difference of
3880
+ // levels that the solved rows don't pin.
3881
+ // `parts`: the response as a sum, each part compact (r, i) and full-index (fr, fi), with the
3882
+ // absolute error of its entries.
3883
+ const read = (parts) => {
3884
+ const eps = 1.1e-16, c = Ct[kT], cs = Cs[kT];
3885
+ let Ir = DI[kT], Ii = w * DIs[kT], err = eps * (Math.abs(DI[kT]) + w * Math.abs(DIs[kT]));
3886
+ for (const p of parts) {
3887
+ const [re, im] = dot(kT, w, p.r, p.i);
3888
+ Ir += re;
3889
+ Ii += im;
3890
+ 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);
3891
+ }
3892
+ if (!cuts || kT >= 2) return { Ir, Ii, err };
3893
+ const sgn = kT === 0 ? 1 : -1;
3894
+ for (const cut of cuts) {
3895
+ let re = 0, im = 0, e = 0;
3896
+ for (const p of parts) {
3897
+ for (let a = 0; a < cut.idx.length; a++) {
3898
+ const x = cut.idx[a], gk = cut.g[a], dk = w * cut.d[a];
3899
+ // (g + iωd)(xr + i xi)
3900
+ re += gk * p.fr[x] - dk * p.fi[x];
3901
+ im += gk * p.fi[x] + dk * p.fr[x];
3902
+ e += (Math.abs(gk) + Math.abs(dk)) * (eps * Math.hypot(p.fr[x], p.fi[x]) + p.floor);
3903
+ }
3904
+ }
3905
+ if (e < err) [err, Ir, Ii] = [e, sgn * re, sgn * im];
3906
+ }
3907
+ return { Ir, Ii, err };
3908
+ };
3909
+ const largest = (r, i) => {
3910
+ let m = 0;
3911
+ for (let j = 0; j < r.length; j++) m = Math.max(m, Math.hypot(r[j], i[j]));
3912
+ return m;
3913
+ };
3914
+ const full = (r, i) => {
3915
+ const fr = new Float64Array(N), fi = new Float64Array(N);
3916
+ for (let j = 0; j < Nc; j++) {
3917
+ fr[this.fullOf[j]] = r[j];
3918
+ fi[this.fullOf[j]] = i[j];
3919
+ }
3920
+ return [fr, fi];
3921
+ };
3214
3922
  let Zr, Zi;
3215
3923
  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];
3924
+ // δI per volt: (C + iωC′)·δx + (∂I/∂V)(1 + iω′) at the terminal…
3925
+ let { Ir, Ii, err } = read([{ r: yr, i: yi, fr: xr, fi: xi, floor: accuracy * largest(yr, yi) }]);
3926
+ // …and where the solves leave that unresolved (the estimate past 1% of the current), the
3927
+ // response is split: a uniform shift of each region (z_i s on every η_i, s on φ̂), which
3928
+ // changes no flux and no concentration, so that J·e vanishes inside each region and is
3929
+ // taken at its edges; and what's left, small, solved for to GMRES's floor, its error now
3930
+ // relative to itself. An electrolyte at its open circuit needs it: the response to its
3931
+ // terminal is nearly such a shift, and its current the slope of levels uniform to 1e-14,
3932
+ // more than a double can hold. (Not with ports, whose exchange sees the shift.)
3933
+ if (K === 0 && cuts && err > 1e-2 * Math.hypot(Ir, Ii)) {
3934
+ const [er, ei] = this._regionShift(yr, yi);
3935
+ const tr = new Float64Array(Nc), ti = new Float64Array(Nc);
3936
+ jv(er, ei, tr, ti);
3937
+ const edge = this._regionEdgeRows();
3938
+ const ar = new Float64Array(Nc), ai = new Float64Array(Nc);
3939
+ for (let j = 0; j < Nc; j++) {
3940
+ ar[j] = -Bt[kT][j] - (edge[j] ? tr[j] : 0);
3941
+ ai[j] = edge[j] ? -ti[j] : 0;
3942
+ }
3943
+ const rhs = precondition(ar, ai);
3944
+ const dr = yr.map((v, j) => v - er[j]), di = yi.map((v, j) => v - ei[j]);
3945
+ const { residual } = gmres(op, rhs, [dr, di], { tol: 1e-11 });
3946
+ const [efr, efi] = full(er, ei), [dfr, dfi] = full(dr, di);
3947
+ const r2 = read([
3948
+ { r: er, i: ei, fr: efr, fi: efi, floor: 0 },
3949
+ { r: dr, i: di, fr: dfr, fi: dfi, floor: Math.max(1e-14, residual) * largest(dr, di) },
3950
+ ]);
3951
+ if (r2.err < err) ({ Ir, Ii, err } = r2);
3952
+ if (profiles) {
3953
+ for (let j = 0; j < N; j++) {
3954
+ xr[j] = efr[j] + dfr[j];
3955
+ xi[j] = efi[j] + dfi[j];
3956
+ }
3957
+ }
3958
+ }
3220
3959
  const d2 = Ir * Ir + Ii * Ii; // Z = 1/δI
3221
3960
  Zr = Ir / d2;
3222
3961
  Zi = -Ii / d2;
@@ -3233,6 +3972,125 @@ export class Solver {
3233
3972
  return out;
3234
3973
  }
3235
3974
 
3975
+ // A uniform shift of each region, fitted to a small-signal response (compact, complex): φ̂ by
3976
+ // s_r and each species' η by z_i s_r, with s_r the region's mean δφ̂ (or, where φ̂ isn't an
3977
+ // unknown, its carriers' mean δη/z). Coverages and flux unknowns aren't shifted.
3978
+ _regionShift(yr, yi) {
3979
+ const { n, M, z, loc, model } = this, R = this.rix, { grid } = model;
3980
+ const er = new Float64Array(yr.length), ei = new Float64Array(yr.length);
3981
+ for (let r = 0; r < model.regions.length; r++) {
3982
+ let sr = 0, si = 0, k = 0;
3983
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r];
3984
+ for (let g = g0; g <= g1; g++) {
3985
+ const o = this.blockOfNode[g] * M;
3986
+ if (loc[o] >= 0) {
3987
+ sr += yr[R[o]];
3988
+ si += yi[R[o]];
3989
+ k++;
3990
+ }
3991
+ }
3992
+ if (k === 0) {
3993
+ for (let g = g0; g <= g1; g++) {
3994
+ const o = this.blockOfNode[g] * M;
3995
+ for (let i = 0; i < n; i++) {
3996
+ if (z[i] === 0 || loc[o + 1 + i] < 0) continue;
3997
+ sr += yr[R[o + 1 + i]] / z[i];
3998
+ si += yi[R[o + 1 + i]] / z[i];
3999
+ k++;
4000
+ }
4001
+ }
4002
+ }
4003
+ if (k === 0) continue;
4004
+ sr /= k;
4005
+ si /= k;
4006
+ for (let g = g0; g <= g1; g++) {
4007
+ const o = this.blockOfNode[g] * M;
4008
+ if (loc[o] >= 0) {
4009
+ er[R[o]] = sr;
4010
+ ei[R[o]] = si;
4011
+ }
4012
+ for (let i = 0; i < n; i++) {
4013
+ if (loc[o + 1 + i] < 0) continue;
4014
+ er[R[o + 1 + i]] = z[i] * sr;
4015
+ ei[R[o + 1 + i]] = z[i] * si;
4016
+ }
4017
+ }
4018
+ }
4019
+ return [er, ei];
4020
+ }
4021
+
4022
+ // Compact rows a region's uniform shift can touch: faces, the nodes beside them, and the two
4023
+ // ends (whatever holds a level from outside). Inside a region, every row is unchanged by it.
4024
+ _regionEdgeRows() {
4025
+ const { M, loc, nB } = this, R = this.rix;
4026
+ const edge = new Uint8Array(this.sys.size);
4027
+ const mark = (b) => {
4028
+ if (b < 0 || b >= nB) return;
4029
+ for (let r = 0; r < M; r++) if (loc[b * M + r] >= 0) edge[R[b * M + r]] = 1;
4030
+ };
4031
+ mark(0);
4032
+ mark(nB - 1);
4033
+ for (let f = 0; f < this.nFaces; f++) {
4034
+ const b = this.blockOfFace[f];
4035
+ mark(b - 1);
4036
+ mark(b);
4037
+ mark(b + 1);
4038
+ }
4039
+ return edge;
4040
+ }
4041
+
4042
+ // The cuts where the impedance may read a small-signal current (null where the current
4043
+ // differs between cuts: a device with ports). Each is the linearised total current toward +x
4044
+ // through one segment of an ideal region, Σ (g + iω d)·δu over the unknowns idx: the
4045
+ // Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the displacement's.
4046
+ _currentCuts() {
4047
+ const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
4048
+ if (model.ports.length > 0) return null;
4049
+ const { grid, materials, regions } = model;
4050
+ const cuts = [];
4051
+ for (let r = 0; r < regions.length; r++) {
4052
+ const reg = regions[r], mat = materials[reg.material];
4053
+ if (mat.conductor || !mat.ideal || reg.mixing > 0) continue;
4054
+ const vel = reg.velocity;
4055
+ for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
4056
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
4057
+ const terms = new Map();
4058
+ const add = (x, g, d) => {
4059
+ const t = terms.get(x) || [0, 0];
4060
+ t[0] += g;
4061
+ t[1] += d;
4062
+ terms.set(x, t);
4063
+ };
4064
+ const phiL = u[bL * M], phiR = u[bR * M], hasPhi = !this.phiUndefined[s];
4065
+ for (let i = 0; i < n; i++) {
4066
+ if (z[i] === 0 || !mat.present[i] || mat.D[i] === 0) continue;
4067
+ const zi = z[i], r1 = 1 + i, cL = c[s * n + i], g = mat.D[i] / h;
4068
+ const pe = (vel * h) / mat.D[i];
4069
+ const d = zi * (phiR - phiL) - pe;
4070
+ const E = Math.expm1(u[bR * M + r1] - u[bL * M + r1] + (uLo[bR * M + r1] - uLo[bL * M + r1]) - pe);
4071
+ const gBc = g * bernoulli(d) * cL;
4072
+ const dNdd = -g * bernoulliDerivative(d) * cL * E;
4073
+ add(bL * M + r1, zi * F * gBc, 0);
4074
+ add(bR * M + r1, -zi * F * gBc * (E + 1), 0);
4075
+ if (hasPhi) {
4076
+ add(bL * M, zi * F * (-zi * dNdd + zi * gBc * E), 0);
4077
+ add(bR * M, zi * F * zi * dNdd, 0);
4078
+ }
4079
+ }
4080
+ // The displacement toward +x, ε(φ_L − φ_R)/h (in φ̂ = φ/V_T).
4081
+ const k = hasPhi ? (mat.epsr * EPS0 * this.VT) / h : 0;
4082
+ if (k > 0) {
4083
+ add(bL * M, 0, k);
4084
+ add(bR * M, 0, -k);
4085
+ }
4086
+ if (terms.size === 0) continue;
4087
+ const idx = [...terms.keys()];
4088
+ cuts.push({ idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
4089
+ }
4090
+ }
4091
+ return cuts;
4092
+ }
4093
+
3236
4094
  // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
3237
4095
  _smallSignalProfiles(xr, xi) {
3238
4096
  const { n, M, z, VT, model } = this;
@@ -3277,6 +4135,7 @@ export class Solver {
3277
4135
  // Jacobian are left partial (the next assemble() starts afresh). Concentrations must be current.
3278
4136
  _assembleBookkeeping(dt) {
3279
4137
  const { model } = this;
4138
+ this.dtNow = dt;
3280
4139
  const { grid, materials, regions } = model;
3281
4140
  this.res.fill(0);
3282
4141
  for (let r = 0; r < regions.length; r++) {
@@ -3362,6 +4221,16 @@ export class Solver {
3362
4221
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
3363
4222
  // a change of drive).
3364
4223
  solveSteady(opts = {}) {
4224
+ this.flattening = true;
4225
+ this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4226
+ try {
4227
+ return this._solveSteadyAll(opts);
4228
+ } finally {
4229
+ this.flattening = false;
4230
+ }
4231
+ }
4232
+
4233
+ _solveSteadyAll(opts) {
3365
4234
  // A terminal that passes current only by charging (a gate, a capacitance with no species
3366
4235
  // through it) has no steady state under a current drive: it charges for ever, or at I = 0
3367
4236
  // keeps whatever charge it started with, which a steady solve doesn't know.
@@ -3399,25 +4268,44 @@ export class Solver {
3399
4268
  if (r.converged) return r;
3400
4269
  // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
3401
4270
  // nothing, each solve warm from the last.
4271
+ let dimmer = false;
3402
4272
  if (this.hasGeneration && opts.continuation !== false) {
3403
4273
  this.u.set(u0);
3404
4274
  this.uLo.set(u0Lo);
3405
4275
  this.termV.set(v0);
3406
4276
  this.computeConcentrations();
4277
+ this.dimmer = false;
3407
4278
  const g = this._generationContinuation(opts, r);
4279
+ dimmer = this.dimmer;
3408
4280
  if (g.converged) {
3409
4281
  this.solvedV = target;
3410
4282
  return g;
3411
4283
  }
3412
4284
  r = g;
3413
4285
  }
4286
+ // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
4287
+ // voltage until the current crosses its target, and float it from there.
4288
+ if (opts.continuation !== false && this.floating.some((k) => this.terms[k].drive.kind === 'I')) {
4289
+ this.u.set(u0);
4290
+ this.uLo.set(u0Lo);
4291
+ this.termV.set(v0);
4292
+ this.computeConcentrations();
4293
+ const q = this._floatingContinuation(opts, r);
4294
+ if (q.converged) return q;
4295
+ r = q;
4296
+ }
3414
4297
  if (!canContinue) {
3415
4298
  if (quick) {
3416
- this.u.set(u0);
3417
- this.uLo.set(u0Lo);
3418
- this.termV.set(v0);
4299
+ // The full pseudo-transient ramp: from the dimmer light's solution where the light's
4300
+ // continuation got anywhere (a lit floating base charges from there as it would in
4301
+ // time), else from the start.
4302
+ if (!dimmer) {
4303
+ this.u.set(u0);
4304
+ this.uLo.set(u0Lo);
4305
+ this.termV.set(v0);
4306
+ }
3419
4307
  this.computeConcentrations();
3420
- const full = this._solveSteady(opts); // the full pseudo-transient ramp
4308
+ const full = this._solveSteady(opts);
3421
4309
  return { ...full, steps: full.steps + r.steps, iterations: full.iterations + r.iterations };
3422
4310
  }
3423
4311
  return r;
@@ -3445,18 +4333,121 @@ export class Solver {
3445
4333
  return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
3446
4334
  }
3447
4335
 
4336
+ // A cold steady solve with a terminal driven by a current, which can fail where the same state
4337
+ // is easy warm (a solar cell's open circuit, from cold on a fine grid): the terminal held at a
4338
+ // voltage instead, whose steady solves have their own continuation, the voltage marched from
4339
+ // the start's until the current crosses its target, the crossing narrowed by bisection, and
4340
+ // the terminal floated again from beside it.
4341
+ _floatingContinuation(opts, r) {
4342
+ const k = this.floating.find((j) => this.terms[j].drive.kind === 'I');
4343
+ const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
4344
+ let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
4345
+ const history = (r.history ?? []).slice();
4346
+ const hold = (V) => {
4347
+ this.terms[k].drive = { kind: 'V', src: { value: V }, R: 0 };
4348
+ this.redrive();
4349
+ const q = this._steadyFromHere(opts);
4350
+ steps += q.steps ?? 0;
4351
+ iterations += q.iterations ?? 0;
4352
+ history.push({ held: V, converged: q.converged });
4353
+ if (q.converged) seen.push([V, this.termI[k]]);
4354
+ return q.converged ? this.termI[k] - target : NaN;
4355
+ };
4356
+ const fail = () => ({ converged: false, steps, iterations, history });
4357
+ const seen = []; // [V, I] of each held solve that converged
4358
+ let Va = this.termV[k], fa, Vb, fb;
4359
+ try {
4360
+ fa = hold(Va);
4361
+ if (!Number.isFinite(fa)) {
4362
+ Va = 0;
4363
+ fa = hold(Va);
4364
+ }
4365
+ if (!Number.isFinite(fa)) return fail();
4366
+ // March, the step growing: up where the current is below its target, since raising a
4367
+ // terminal's voltage raises the current into a passive device (the other way, from the
4368
+ // start again, if 20 V brings no crossing).
4369
+ const start = Va, f0 = fa;
4370
+ [Vb, fb] = [Va, fa];
4371
+ for (const dir of [fa < 0 ? 1 : -1, fa < 0 ? -1 : 1]) {
4372
+ [Va, fa] = [start, f0];
4373
+ if (dir !== (f0 < 0 ? 1 : -1)) hold(start); // (back to the start, for a warm march)
4374
+ let dV = 0.02;
4375
+ Vb = Va + dir * dV;
4376
+ fb = hold(Vb);
4377
+ while (Number.isFinite(fb) && Math.sign(fb) === Math.sign(fa) && fa !== 0 && Math.abs(Vb - start) < 20) {
4378
+ [Va, fa] = [Vb, fb];
4379
+ dV *= 1.5;
4380
+ Vb = Va + dir * dV;
4381
+ fb = hold(Vb);
4382
+ }
4383
+ if (Number.isFinite(fb) && (Math.sign(fb) !== Math.sign(fa) || fa === 0)) break;
4384
+ }
4385
+ if (!Number.isFinite(fb) || (Math.sign(fb) === Math.sign(fa) && fa !== 0)) {
4386
+ // What the held voltages passed, at the two extremes reached.
4387
+ if (seen.length > 1) {
4388
+ seen.sort((a, b) => a[0] - b[0]);
4389
+ this.unreached = { terminal: this.terms[k].name, target, low: seen[0], high: seen.at(-1) };
4390
+ }
4391
+ return fail();
4392
+ }
4393
+ // Bisect (each held solve warm from the last) to a tenth of a millivolt.
4394
+ for (let it = 0; it < 40 && Math.abs(Vb - Va) > 1e-4; it++) {
4395
+ const Vm = (Va + Vb) / 2, fm = hold(Vm);
4396
+ if (!Number.isFinite(fm)) return fail();
4397
+ if (Math.sign(fm) === Math.sign(fa)) [Va, fa] = [Vm, fm];
4398
+ else [Vb, fb] = [Vm, fm];
4399
+ }
4400
+ hold(Math.abs(fa) < Math.abs(fb) ? Va : Vb);
4401
+ } finally {
4402
+ this.terms[k].drive = drive;
4403
+ this.redrive();
4404
+ }
4405
+ const q = this._solveSteady(opts);
4406
+ steps += q.steps ?? 0;
4407
+ iterations += q.iterations ?? 0;
4408
+ history.push(...(q.history ?? []));
4409
+ if (q.converged) return { ...q, steps, iterations, history };
4410
+ // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
4411
+ // limit: its bordered row 33 digits short), but a current-driven steady state is the held
4412
+ // one at the voltage that passes the target: found by held solves, by regula falsi
4413
+ // (Illinois) within the bracket, to 1e-10 of the target.
4414
+ try {
4415
+ let side = 0;
4416
+ for (let it = 0; it < 60; it++) {
4417
+ const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
4418
+ if (!Number.isFinite(fm)) return fail();
4419
+ if (Math.abs(fm) <= 1e-10 * Math.abs(target)) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4420
+ if (Math.sign(fm) === Math.sign(fb)) {
4421
+ [Vb, fb] = [Vm, fm];
4422
+ if (side === -1) fa /= 2;
4423
+ side = -1;
4424
+ } else {
4425
+ [Va, fa] = [Vm, fm];
4426
+ if (side === 1) fb /= 2;
4427
+ side = 1;
4428
+ }
4429
+ }
4430
+ return fail();
4431
+ } finally {
4432
+ this.terms[k].drive = drive;
4433
+ this.redrive();
4434
+ }
4435
+ }
4436
+
3448
4437
  // Steady solves with the generation reactions' rates scaled from 1e-12 up to 1, ×100 a step
3449
4438
  // while they converge, smaller steps where they don't.
3450
4439
  _generationContinuation(opts, r) {
3451
4440
  const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
3452
- let s = 1e-12, factor = 100, steps = r.steps, iterations = r.iterations;
4441
+ let s = 1e-30, factor = 100, steps = r.steps, iterations = r.iterations;
3453
4442
  const history = r.history.slice();
3454
4443
  try {
3455
4444
  this.generationScale = s;
4445
+ // (nearly dark: a cold start can need the full ramp, as a dark device's does)
3456
4446
  let q = this._solveSteady(sub);
3457
4447
  steps += q.steps;
3458
4448
  iterations += q.iterations;
3459
4449
  if (!q.converged) return { converged: false, steps, iterations, history };
4450
+ this.dimmer = true; // (the state is now a solution under dimmer light: a better start than cold)
3460
4451
  while (s < 1) {
3461
4452
  const next = Math.min(1, s * factor);
3462
4453
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
@@ -3474,7 +4465,7 @@ export class Solver {
3474
4465
  this.termV.set(v1);
3475
4466
  this.computeConcentrations();
3476
4467
  factor = Math.sqrt(factor);
3477
- if (factor < 1.01) return { converged: false, steps, iterations, history };
4468
+ if (factor < 1.5) return { converged: false, steps, iterations, history }; // (crawling: time steps do better)
3478
4469
  }
3479
4470
  }
3480
4471
  return { converged: true, steps, iterations, history };
@@ -3492,6 +4483,17 @@ export class Solver {
3492
4483
  let q;
3493
4484
  if (!warm) {
3494
4485
  q = this._solveSteady(opts);
4486
+ if (!q.converged) {
4487
+ // A cold device's start was laid out for the target (a set() before the first solve
4488
+ // builds it there): lay it out again, level.
4489
+ steps += q.steps;
4490
+ iterations += q.iterations;
4491
+ this._refreshSources();
4492
+ this.initFromComposition();
4493
+ q = this._solveSteady(opts);
4494
+ }
4495
+ // (lit, the level start may need the light ramped up too)
4496
+ if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
3495
4497
  steps += q.steps;
3496
4498
  iterations += q.iterations;
3497
4499
  if (!q.converged) return { ...r, steps, iterations };
@@ -3540,7 +4542,8 @@ export class Solver {
3540
4542
  const direct = this._directSteady();
3541
4543
  const giant = direct ? Infinity : 1e6 * tau;
3542
4544
  if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
3543
- let dt = giant;
4545
+ let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
4546
+ let triedDirect = 0;
3544
4547
  let totalIter = 0, steps = 0, converged = false;
3545
4548
  const history = [];
3546
4549
  while (steps < maxSteps) {
@@ -3561,6 +4564,7 @@ export class Solver {
3561
4564
  // Pseudo-transient continuation: down from a slow time scale until a step converges,
3562
4565
  // as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
3563
4566
  dt = dt >= giant ? tau * 1e-6 : dt / 4;
4567
+ grow = 2;
3564
4568
  if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
3565
4569
  continue;
3566
4570
  }
@@ -3568,6 +4572,21 @@ export class Solver {
3568
4572
  converged = true; // the steady equations themselves were solved
3569
4573
  break;
3570
4574
  }
4575
+ // Past the slowest diffusion time, the direct solve may already reach from here: try it
4576
+ // once a decade (the state kept if it fails), rather than step on to the giant dt through
4577
+ // steps that can fail for slow ions' sake.
4578
+ if (direct && dt < giant && dt >= tau && dt >= 10 * triedDirect) {
4579
+ triedDirect = dt;
4580
+ const snap = this._snapshot();
4581
+ const d = this.step(Infinity);
4582
+ totalIter += d.iterations;
4583
+ history.push({ dt: Infinity, converged: d.converged, iterations: d.iterations });
4584
+ if (d.converged) {
4585
+ converged = true;
4586
+ break;
4587
+ }
4588
+ this._restore(snap);
4589
+ }
3571
4590
  if (dt >= giant && this._maxPotentialStep(this._diff()) < tol) {
3572
4591
  converged = true;
3573
4592
  if (dt > giant) {
@@ -3580,7 +4599,8 @@ export class Solver {
3580
4599
  break;
3581
4600
  }
3582
4601
  if (dt < giant) {
3583
- dt *= 10; // ramping up after a failure
4602
+ dt *= grow; // ramping up after a failure
4603
+ grow = Math.min(10, grow * 1.5);
3584
4604
  if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
3585
4605
  } else if (dt < 1e6 * giant) {
3586
4606
  dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)