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