driftlet 1.2.4 → 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/steady.js ADDED
@@ -0,0 +1,754 @@
1
+ // Steady solves: the state a transient from the present one ends in, found without waiting it
2
+ // out, by Newton on the steady equations where it reaches, by continuation along the drives
3
+ // where it doesn't, and by pseudo-transient time steps where nothing else does.
4
+ //
5
+ // CONTRACT
6
+ //
7
+ // Methods of the Solver (src/solver.js; its layout is in docs/internals.md), installed on its
8
+ // prototype like the assembly's. Device.solve and Device.impedance (src/index.js) call
9
+ // solveSteady, from a snapshot that Device.solve restores where it fails; time steps
10
+ // (src/timeSteps.js) read fastestTime; Solution and the checks read stuckReactions. What they
11
+ // use of the rest:
12
+ // this.step(dt, opts) one time step (src/timeSteps.js), whose contract this leans on: at
13
+ // dt = ∞ with this.steady, the steady equations with the laws' rows in
14
+ // place (src/lawRows.js), sources at this.steadyAt; accepted, the time
15
+ // moves on by dt and the history gains the step; failed, all as before
16
+ // it.
17
+ // captureLaws, captureLocal (src/lawRows.js) the amounts and charges the laws hold, taken
18
+ // from the state when a solve begins; laws.js's findLaws (through
19
+ // this._laws, on a change of drive) says which laws there are, and
20
+ // this.unkept which no row can keep.
21
+ // this.redrive(), this._refreshSources(), this.sourceOverride a terminal's drive, changed
22
+ // and put back; the held voltages read at this.sourceTime, or the
23
+ // override where one is set. A redrive() that changes how a terminal is
24
+ // driven (a voltage, a current) finds the laws again (this._laws): new
25
+ // constraints, local constraints with their references at 0, islands
26
+ // off, no pins; this.atSteady false, the history empty.
27
+ // this.initFromComposition() (src/start.js), this._snapshot()/_restore(), this.amount(st),
28
+ // this.computeConcentrations(), maxPotentialStep (src/newton.js).
29
+ //
30
+ // solveSteady(opts) → { converged, steps, iterations, history, residual?, ramped? }
31
+ // opts: maxSteps (80: time steps per pseudo-transient ramp), tol (1e-11: the largest potential
32
+ // change, in thermal units, that ends a ramp of giant steps), continuation (false: Newton from
33
+ // here and the pseudo-transient ramp only). Sets this.violent (violentStart, where nothing has
34
+ // been stepped or solved), clears this.unreached, this.charging and this.drifted, captures the
35
+ // laws, and
36
+ // solves with this.steady = true and this.steadyAt = this.time (false again on return).
37
+ // Stops at once, converged false, steps 0, where a floating terminal is driven by a nonzero
38
+ // current that only charges a capacitance or feeds a conserved combination (a constraint):
39
+ // this.charging says which terminal and what it fills. Else _steadyFromHere, and
40
+ // this.atSteady = converged. Not converged: this.solvedV and this.stepped as before the call.
41
+ //
42
+ // _steadyFromHere(opts): in stages until one converges, each from the state the solve began in
43
+ // (u, uLo and termV, put back between stages, the concentrations recomputed) unless it says
44
+ // otherwise. A continuation applies with continuation not off; canContinue: both contacts held
45
+ // at voltages (not behind a resistance) that differ.
46
+ // 1. Newton from here (_steadySteps): a single step (maxSteps 1) where canContinue or there's
47
+ // generation, and the device can be solved directly (_directSteady); else the
48
+ // pseudo-transient ramp with opts' maxSteps. Failing on a state reached by time steps
49
+ // (this.stepped), on a direct device with continuation not off, the same again from a cold
50
+ // start (initFromComposition), whose u and uLo become the stages' start (termV stays the
51
+ // start's: a floating terminal keeps the voltage the transient left it at).
52
+ // 2. Continuation: generation ramped up (_generationContinuation); a terminal driven by a
53
+ // current, not bound (_bound), held at voltages instead (_floatingContinuation); with
54
+ // canContinue, the right contact's voltage ramped to its target (_continuation): from the
55
+ // voltages of the last converged solve (this.solvedV), the left contact's first where it
56
+ // moved, else from level contacts, a cold start.
57
+ // 3. The pseudo-transient ramp: after the contact's ramp fails, from the start; otherwise only
58
+ // where stage 1 was a single step, from the dimmer light's solution where the generation
59
+ // ramp got past its first solve (u, uLo and termV as that ramp left them), else from the
60
+ // start.
61
+ // Converged (but by _floatingContinuation): this.solvedV = [termV[0], termV[1]], the contacts'
62
+ // own voltages at the solution (driven by a current or behind a resistance too), this.stepped =
63
+ // false. (By _floatingContinuation: as its last held solve set them.)
64
+ //
65
+ // _steadySteps({ maxSteps, tol }): up to maxSteps time steps (tau = slowestTime), dt from giant
66
+ // (∞ where the device can be solved directly; else 1e6·tau): a first step at ∞ that fails is
67
+ // retried once with maxStep 3. A step that fails sends dt down (to tau·1e-6 from giant, then
68
+ // ÷4, stopping below min(1e-15·tau, 1e-2·fastestTime)); one that converges sends it back up
69
+ // (×2 after a failure, growing by ×1.5 to ×10 a step; past 1e6·tau, giant); on a direct device,
70
+ // once dt ≥ tau, a step at ∞ is tried once a decade of dt, the state kept where it fails. Past
71
+ // giant (not direct), dt grows ×10 a step to 1e6·giant. Converged where a step at ∞ converged,
72
+ // or, not direct, where a step of dt ≥ giant moved the potentials under tol (finished by one
73
+ // step of exactly giant where dt had grown past it, which must converge too). Not direct,
74
+ // before each step of dt ≥ giant and the finishing one, each spectator's amount restored
75
+ // (_renormalizeSpectators). Not direct, with unkept laws: after each step of dt ≥ giant, each
76
+ // unkept combination of stretches' and surfaces' amounts (_unkeptAmounts) must be within 1e-6
77
+ // of its size (Σ |w·amount|) of what it was when the call began, else the step is put back
78
+ // (_restore) and counts as failed (`drifted` in its history entry; this.drifted names the
79
+ // combination's parts); the finishing step, failing that, ends the call unconverged. (A
80
+ // combination through an electrode's or a contact's charge isn't checked.) On return, whatever
81
+ // the outcome: this.time as it was, the history
82
+ // empty, this.dtNext undefined; converged, every connected stretch's referenceAmounts its
83
+ // amount now and its boundaryIntake 0.
84
+ //
85
+ // The continuations (each warm from the last converged solve; a solve that fails puts back
86
+ // u, uLo and termV and takes a smaller step):
87
+ // _generationContinuation generationScale from 1e-30 up to 1, by a factor of 100 at most
88
+ // (squared after a success, its square root after a failure, giving
89
+ // up below 1.5); fails if the first, at 1e-30, does; this.dimmer once
90
+ // it converges. generationScale is 1 again on return.
91
+ // _continuation(k, from, to) contact k's voltage through sourceOverride (the other's held
92
+ // too, where given), in steps of an eighth of the span, ×1.5 on
93
+ // success, ÷4 on a failure, giving up below 1e-6 of the span. Cold
94
+ // (not warm), first solved at `from`: failing, again from a cold start
95
+ // laid out there, then with the light ramped where there's
96
+ // generation. The overrides are cleared on return.
97
+ // _floatingContinuation the terminal held at voltages (its drive replaced, then put back):
98
+ // marched from its voltage (or 0) in steps from 20 mV, ×1.5, toward
99
+ // the target current (both ways, to ±20 V), the crossing bisected to
100
+ // 0.1 mV, then floated from the nearer end. Floated and failing, the
101
+ // held solve whose current is within 1e-10 of the target (by regula
102
+ // falsi, Illinois, from the bracket; the scale is the largest current
103
+ // seen) is returned as converged, its drive put back. No crossing:
104
+ // this.unreached says what the held voltages passed. Each time its
105
+ // drive is put back, the local constraints' references are taken from
106
+ // the state again (captureLocal), which redrive() zeroed.
107
+ //
108
+ // Also: slowestTime() = L²/D_min over the species present with D > 0 in the materials some
109
+ // region is made of (1 where none), fastestTime() = h_min²/D_max over the same (slowestTime where
110
+ // there's no such cell); _directSteady(): no law unkept and every
111
+ // stretch connected or conserved; stuckReactions(): after a steady solve, each reaction that
112
+ // alone changes a combination nothing feeds and whose affinity keeps one sign, over 1e-6, wherever
113
+ // it acts (see its comment).
114
+ //
115
+ // Invariants (each says what a reviewer should be able to check here):
116
+ // 1. Converged: the state solves the steady equations as step(∞) solved them; or, not direct,
117
+ // a giant step's (whose storage term is 1e-6/tau of the rest at most); or, by
118
+ // _floatingContinuation's regula falsi, the held equations at a voltage whose current is
119
+ // within 1e-10 of the target's scale.
120
+ // 2. On return: this.time, every terminal's drive, sourceOverride, generationScale and
121
+ // this.steady as before the call; the history empty, but where the call stopped at once
122
+ // (this.charging) before any step.
123
+ // 3. A stage that fails leaves no trace in the next one's start: u, uLo and termV are put back
124
+ // (the concentrations recomputed); only stage 1's cold start, on a stepped state, moves the
125
+ // start, and stage 3 may begin from the generation ramp's solution.
126
+ // 4. Deterministic: the same state and options give the same result, bit for bit. The state
127
+ // here is more than u, uLo, termV and time: this.solvedV, this.stepped, whether the history
128
+ // is empty (this.violent) and the drives, all of which choose the stages.
129
+ // 5. Reach: time steps reach a mode as slow as ~1e12·tau (1e6·tau a step, past the giant; on a
130
+ // direct device no finite step is longer than 1e6·tau). A reaction that alone settles a
131
+ // closed combination, slower than that, isn't waited out: the solve fails or converges where
132
+ // it began, and stuckReactions names it (Solution's warnings, both ways).
133
+ // Tests: every Device.solve goes through here; test/steady.test.js (the stages, the laws, the
134
+ // continuations) and test/generation.test.js; the devices that needed each stage are named in its
135
+ // comments.
136
+
137
+ import { sourceAt } from './device.js';
138
+ import { chargeOnly, conservedMoieties, feeds, noCurrent } from './laws.js';
139
+ import { captureLaws, captureLocal } from './lawRows.js';
140
+ import { maxPotentialStep } from './newton.js';
141
+ import { violentStart } from './timeSteps.js';
142
+
143
+ export class SteadySolves {
144
+ // Slowest diffusion time across the device, used to size "giant" steps.
145
+ // (Of the materials some region is made of: one defined but unused, its D = 1e-22, made a
146
+ // giant step 1e7 times longer, too long to keep a fast species' amount: see _steadySteps.)
147
+ slowestTime() {
148
+ const { model } = this;
149
+ let dMin = Infinity;
150
+ for (const mat of this._usedMaterials()) {
151
+ for (let i = 0; i < this.n; i++) if (mat.present[i] && mat.D[i] > 0) dMin = Math.min(dMin, mat.D[i]);
152
+ }
153
+ const L = model.grid.length;
154
+ return Number.isFinite(dMin) ? (L * L) / dMin : 1;
155
+ }
156
+
157
+ _usedMaterials() {
158
+ return [...new Set(this.model.regions.map((reg) => reg.material))].map((m) => this.model.materials[m]);
159
+ }
160
+
161
+ // The fastest diffusion time: across the smallest cell, at the largest D.
162
+ fastestTime() {
163
+ let dMax = 0;
164
+ for (const mat of this._usedMaterials()) {
165
+ for (let i = 0; i < this.n; i++) if (mat.present[i]) dMax = Math.max(dMax, mat.D[i]);
166
+ }
167
+ let h = Infinity;
168
+ for (const s of this.model.grid.segLength) if (s > 0) h = Math.min(h, s);
169
+ return dMax > 0 && Number.isFinite(h) ? (h * h) / dMax : this.slowestTime();
170
+ }
171
+
172
+ /**
173
+ * Steady state: where a transient from the present state ends, found as the solution of the
174
+ * steady equations (dt = ∞, no storage term), with no slow transport to wait out (e.g.
175
+ * exponentially scarce minority carriers); but a slow reaction that alone settles a closed
176
+ * combination is waited out by time steps, to ~1e12 of the slowest diffusion time (invariant
177
+ * 5). What the storage term held is held by
178
+ * the steady system's own rows instead (see src/laws.js): each conserved amount in place of a balance
179
+ * row that says nothing new, levels that carry nothing held flat, islands' summed balances.
180
+ * Where Newton from here fails: continuation along the drives, then the pseudo-transient ramp.
181
+ * The clock is not advanced, and open-system conservation bookkeeping restarts here.
182
+ */
183
+ // Steady state from the present one; `atSteady` says whether the state is one (until a step or
184
+ // a change of drive).
185
+ solveSteady(opts = {}) {
186
+ this.violent = this.history.length === 0 && !this.stepped ? violentStart(this) : []; // (for a failure to say: see src/timeSteps.js)
187
+ this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
188
+ this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
189
+ captureLaws(this);
190
+ this.steady = true; // (the steady system's rows; sources at the present time)
191
+ this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
192
+ this.drifted = null; // (an amount no row keeps, lost by a giant step: see _steadySteps)
193
+ // A solve that fails leaves where the state was solved, and whether it was stepped, as they
194
+ // were (Device.solve restores the state): a floating continuation's held solves had set them,
195
+ // and the next held solve ramped warm from a voltage the state was never at, and failed.
196
+ const was = [this.solvedV, this.stepped];
197
+ try {
198
+ const r = this._solveSteadyAll(opts);
199
+ if (!r.converged) [this.solvedV, this.stepped] = was;
200
+ return r;
201
+ } finally {
202
+ this.steady = false;
203
+ }
204
+ }
205
+
206
+ _solveSteadyAll(opts) {
207
+ // A terminal driven by a current that can only store what comes in (a battery's cathode
208
+ // behind it, closed but for that terminal; a gate, or a capacitance with no species through
209
+ // it) has no steady state but at no current, where it keeps what it holds.
210
+ const { species, regions } = this.model;
211
+ for (const k of this.floating) {
212
+ const I = sourceAt(this.terms[k].drive.src, this.time);
213
+ if (this.terms[k].drive.kind !== 'I' || I === 0) continue;
214
+ const laws = this.constraints.filter((cs) => cs.terminals.includes(k));
215
+ if (laws.length === 0 && !chargeOnly(this, k)) continue;
216
+ const what = laws.flatMap((cs) => cs.parts.map(({ stretch }) => {
217
+ const st = this.stretches[stretch], [r0, r1] = st.regions;
218
+ return `${species[st.species].name} in ${regions[r0].name}${r1 > r0 ? `…${regions[r1].name}` : ''}`;
219
+ }));
220
+ this.charging = { terminal: this.terms[k].name, current: I, what };
221
+ return { converged: false, steps: 0, iterations: 0, history: [] };
222
+ }
223
+ const r = this._steadyFromHere(opts);
224
+ this.atSteady = r.converged;
225
+ return r;
226
+ }
227
+
228
+ // Steady state from the present one, in three stages: Newton from here; failing that, from a
229
+ // solved state along the drives (the light ramped up, a current's terminal held at the voltages
230
+ // that pass it, the voltages ramped from where they were last solved); failing that, the
231
+ // pseudo-transient ramp, through time.
232
+ _steadyFromHere(opts) {
233
+ this.conditioning = null;
234
+ this.computeConcentrations();
235
+ captureLocal(this); // immobile combinations keep, node by node, what they hold now
236
+ this.sourceTime = this.time;
237
+ // Continuation applies where the right terminal is held at a voltage, against a held left one.
238
+ const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
239
+ const held = (d) => d.kind === 'V' && !(d.R > 0);
240
+ const target = sourceAt(dr.src, this.time), level = sourceAt(dl.src, this.time);
241
+ const allowed = opts.continuation !== false;
242
+ const canContinue = allowed && held(dl) && held(dr) && target !== level;
243
+ const direct = this._directSteady();
244
+ const u0 = Float64Array.from(this.u), u0Lo = Float64Array.from(this.uLo), v0 = Float64Array.from(this.termV);
245
+ const restart = () => {
246
+ this.u.set(u0);
247
+ this.uLo.set(u0Lo);
248
+ this.termV.set(v0);
249
+ this.computeConcentrations();
250
+ };
251
+ const solved = (q) => {
252
+ if (q.converged) {
253
+ this.solvedV = [this.termV[0], this.termV[1]]; // (a contact's own: driven by a current, not the current)
254
+ this.stepped = false;
255
+ }
256
+ return q;
257
+ };
258
+ const after = (q, r) => ({ ...q, steps: q.steps + r.steps, iterations: q.iterations + r.iterations });
259
+
260
+ // 1. Newton from here. Where a continuation could follow, one direct attempt only, rather
261
+ // than the pseudo-transient ramp straight away.
262
+ const quick = (canContinue || this.hasGeneration) && direct && allowed;
263
+ let r = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
264
+ if (r.converged) return solved(r);
265
+ // A transient's state can be a worse start than a cold one: 1 ns after a lit MOS capacitor's
266
+ // gate jumps, its surface is in deep depletion, minority carriers ten orders below where they
267
+ // end, and Newton fails from there where it converges from the layout for the drives (and
268
+ // the stages below took 9000 iterations from there, 2000 from the layout). What a steady
269
+ // solve keeps it took from the state already, so from here on it goes as a cold device would.
270
+ if (this.stepped && direct && allowed) {
271
+ this._refreshSources();
272
+ this.initFromComposition();
273
+ u0.set(this.u);
274
+ u0Lo.set(this.uLo);
275
+ const q = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
276
+ if (q.converged) return solved(after(q, r));
277
+ restart();
278
+ r = after(q, r);
279
+ }
280
+
281
+ // 2. Continuation. Generation (e.g. light) holding the device far from equilibrium: ramp it up
282
+ // from nearly nothing, each solve warm from the last.
283
+ let dimmer = null;
284
+ if (this.hasGeneration && allowed) {
285
+ restart();
286
+ this.dimmer = false;
287
+ const g = this._generationContinuation(opts, r);
288
+ if (this.dimmer) dimmer = [Float64Array.from(this.u), Float64Array.from(this.uLo), Float64Array.from(this.termV)];
289
+ if (g.converged) return solved(g);
290
+ r = g;
291
+ }
292
+ // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
293
+ // voltage until the current crosses its target, and float it from there.
294
+ if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
295
+ restart();
296
+ const q = this._floatingContinuation(opts, r);
297
+ if (q.converged) return q;
298
+ r = q;
299
+ }
300
+ if (canContinue) {
301
+ // The right terminal's voltage ramped to its target: from the contacts' voltages at the last
302
+ // converged solve when the state is that solution (a warm start), the left one first where
303
+ // it moved, the right held where it was; otherwise from level terminals, consistent with a
304
+ // cold start.
305
+ restart();
306
+ const was = this.solvedV;
307
+ let c;
308
+ if (was) {
309
+ c = was[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, was[0], level, r, true, was[1]);
310
+ // (Ramping the right from level terminals, solve there first: a sweep's start.)
311
+ if (c.converged) c = this._continuation(opts, 1, was[1], target, c, c.ramped || was[1] !== level);
312
+ } else c = this._continuation(opts, 1, level, target, r, false);
313
+ if (c.converged) return solved(c);
314
+ // 3. The pseudo-transient ramp, from the start.
315
+ restart();
316
+ return solved(after(this._steadySteps(opts), c));
317
+ }
318
+ // 3. The pseudo-transient ramp (where the first attempt wasn't it already): from the dimmer
319
+ // light's solution where the light's continuation got anywhere (a lit floating base charges
320
+ // from there as it would in time), else from the start.
321
+ if (!quick) return r;
322
+ if (!dimmer) restart();
323
+ else {
324
+ // (where it was left, not where a floating continuation since left the state)
325
+ this.u.set(dimmer[0]);
326
+ this.uLo.set(dimmer[1]);
327
+ this.termV.set(dimmer[2]);
328
+ this.computeConcentrations();
329
+ }
330
+ return after(this._steadySteps(opts), r);
331
+ }
332
+
333
+ // Whether terminal k, driven by a current, feeds a combination that it alone can change, or
334
+ // only charges a capacitance (so that its steady states are one per amount or charge, not one
335
+ // per voltage).
336
+ _bound(k) {
337
+ return this.constraints.some((cs) => cs.terminals.includes(k)) || chargeOnly(this, k);
338
+ }
339
+
340
+ // A cold steady solve with a terminal driven by a current, which can fail where the same state
341
+ // is easy warm (a solar cell's open circuit, from cold on a fine grid): the terminal held at a
342
+ // voltage instead, whose steady solves have their own continuation, the voltage marched from
343
+ // the start's until the current crosses its target, the crossing narrowed by bisection, and
344
+ // the terminal floated again from beside it.
345
+ _floatingContinuation(opts, r) {
346
+ const k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j));
347
+ const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
348
+ // Its drive put back: redrive() finds the laws again for it, with the local constraints'
349
+ // references at zero, so they're taken from the state again (an immobile species held at 0
350
+ // at every node: the floated solve failed, 80 steps, and the regula falsi rescued it at 6×).
351
+ const floatAgain = () => {
352
+ this.terms[k].drive = drive;
353
+ this.redrive();
354
+ captureLocal(this);
355
+ };
356
+ let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
357
+ const history = (r.history ?? []).slice();
358
+ const hold = (V) => {
359
+ this.terms[k].drive = { kind: 'V', src: { value: V }, R: 0 };
360
+ this.redrive();
361
+ const q = this._steadyFromHere(opts);
362
+ steps += q.steps ?? 0;
363
+ iterations += q.iterations ?? 0;
364
+ history.push({ held: V, converged: q.converged });
365
+ if (q.converged) seen.push([V, this.termI[k]]);
366
+ return q.converged ? this.termI[k] - target : NaN;
367
+ };
368
+ const fail = () => ({ converged: false, steps, iterations, history });
369
+ const seen = []; // [V, I] of each held solve that converged
370
+ let Va = this.termV[k], fa, Vb, fb;
371
+ try {
372
+ fa = hold(Va);
373
+ if (!Number.isFinite(fa)) {
374
+ Va = 0;
375
+ fa = hold(Va);
376
+ }
377
+ if (!Number.isFinite(fa)) return fail();
378
+ // March, the step growing: up where the current is below its target, since raising a
379
+ // terminal's voltage raises the current into a passive device (the other way, from the
380
+ // start again, if 20 V brings no crossing).
381
+ const start = Va, f0 = fa;
382
+ [Vb, fb] = [Va, fa];
383
+ for (const dir of [fa < 0 ? 1 : -1, fa < 0 ? -1 : 1]) {
384
+ [Va, fa] = [start, f0];
385
+ if (dir !== (f0 < 0 ? 1 : -1)) hold(start); // (back to the start, for a warm march)
386
+ let dV = 0.02;
387
+ Vb = Va + dir * dV;
388
+ fb = hold(Vb);
389
+ while (Number.isFinite(fb) && Math.sign(fb) === Math.sign(fa) && fa !== 0 && Math.abs(Vb - start) < 20) {
390
+ [Va, fa] = [Vb, fb];
391
+ dV *= 1.5;
392
+ Vb = Va + dir * dV;
393
+ fb = hold(Vb);
394
+ }
395
+ if (Number.isFinite(fb) && (Math.sign(fb) !== Math.sign(fa) || fa === 0)) break;
396
+ }
397
+ if (!Number.isFinite(fb) || (Math.sign(fb) === Math.sign(fa) && fa !== 0)) {
398
+ // What the held voltages passed, at the two extremes reached.
399
+ if (seen.length > 1) {
400
+ seen.sort((a, b) => a[0] - b[0]);
401
+ this.unreached = { terminal: this.terms[k].name, target, low: seen[0], high: seen.at(-1) };
402
+ }
403
+ return fail();
404
+ }
405
+ // Bisect (each held solve warm from the last) to a tenth of a millivolt.
406
+ for (let it = 0; it < 40 && Math.abs(Vb - Va) > 1e-4; it++) {
407
+ const Vm = (Va + Vb) / 2, fm = hold(Vm);
408
+ if (!Number.isFinite(fm)) return fail();
409
+ if (Math.sign(fm) === Math.sign(fa)) [Va, fa] = [Vm, fm];
410
+ else [Vb, fb] = [Vm, fm];
411
+ }
412
+ hold(Math.abs(fa) < Math.abs(fb) ? Va : Vb);
413
+ } finally {
414
+ floatAgain();
415
+ }
416
+ const q = this._steadySteps(opts);
417
+ steps += q.steps ?? 0;
418
+ iterations += q.iterations ?? 0;
419
+ history.push(...(q.history ?? []));
420
+ if (q.converged) return { ...q, steps, iterations, history };
421
+ // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
422
+ // limit: its bordered row 33 digits short), but a current-driven steady state is the held
423
+ // one at the voltage that passes the target: found by held solves, by regula falsi
424
+ // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
425
+ // current the march saw (a target of 0 is never met exactly).
426
+ const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
427
+ try {
428
+ let side = 0;
429
+ for (let it = 0; it < 60; it++) {
430
+ const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
431
+ if (!Number.isFinite(fm)) return fail();
432
+ if (Math.abs(fm) <= 1e-10 * scale) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
433
+ if (Math.sign(fm) === Math.sign(fb)) {
434
+ [Vb, fb] = [Vm, fm];
435
+ if (side === -1) fa /= 2;
436
+ side = -1;
437
+ } else {
438
+ [Va, fa] = [Vm, fm];
439
+ if (side === 1) fb /= 2;
440
+ side = 1;
441
+ }
442
+ }
443
+ return fail();
444
+ } finally {
445
+ floatAgain();
446
+ }
447
+ }
448
+
449
+ // Steady solves with the generation reactions' rates scaled from 1e-30 up to 1, ×100 a step
450
+ // while they converge, smaller steps where they don't.
451
+ _generationContinuation(opts, r) {
452
+ const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
453
+ let s = 1e-30, factor = 100, steps = r.steps, iterations = r.iterations;
454
+ const history = r.history.slice();
455
+ try {
456
+ this.generationScale = s;
457
+ // (nearly dark: a cold start can need the full ramp, as a dark device's does)
458
+ let q = this._steadySteps(sub);
459
+ steps += q.steps;
460
+ iterations += q.iterations;
461
+ if (!q.converged) return { converged: false, steps, iterations, history };
462
+ this.dimmer = true; // (the state is now a solution under dimmer light: a better start than cold)
463
+ while (s < 1) {
464
+ const next = Math.min(1, s * factor);
465
+ const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
466
+ this.generationScale = next;
467
+ q = this._steadySteps(sub);
468
+ steps += q.steps;
469
+ iterations += q.iterations;
470
+ history.push({ generation: next, converged: q.converged });
471
+ if (q.converged) {
472
+ s = next;
473
+ factor = Math.min(100, factor * factor);
474
+ } else {
475
+ this.u.set(u1);
476
+ this.uLo.set(u1Lo);
477
+ this.termV.set(v1);
478
+ this.computeConcentrations();
479
+ factor = Math.sqrt(factor);
480
+ if (factor < 1.5) return { converged: false, steps, iterations, history }; // (crawling: time steps do better)
481
+ }
482
+ }
483
+ return { converged: true, steps, iterations, history };
484
+ } finally {
485
+ this.generationScale = 1;
486
+ }
487
+ }
488
+
489
+ // Terminal k (a contact) ramped from `level` to `target` through steady solves, each warm from
490
+ // the last (`hold`: the other contact's voltage meanwhile, if not its own). A warm ramp that has
491
+ // nowhere to go converges only if what it started from did (`ramped`): the state it carries
492
+ // must be some ramp's solution, never just the one a failed solve left.
493
+ _continuation(opts, k, level, target, r, warm, hold) {
494
+ const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
495
+ let V = level, dV = (target - level) / 8, steps = r.steps, iterations = r.iterations;
496
+ const history = r.history.slice();
497
+ if (warm && V === target) return { ...r, converged: r.ramped === true, steps, iterations, history, ramped: r.ramped };
498
+ try {
499
+ this.sourceOverride.set(k, V);
500
+ if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
501
+ let q;
502
+ if (!warm) {
503
+ q = this._steadySteps(opts);
504
+ if (!q.converged) {
505
+ // A cold device's start was laid out for the target (a set() before the first solve
506
+ // builds it there): lay it out again, level.
507
+ steps += q.steps;
508
+ iterations += q.iterations;
509
+ this._refreshSources();
510
+ this.initFromComposition();
511
+ q = this._steadySteps(opts);
512
+ }
513
+ // (lit, the level start may need the light ramped up too)
514
+ if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
515
+ steps += q.steps;
516
+ iterations += q.iterations;
517
+ if (!q.converged) return { ...r, steps, iterations };
518
+ }
519
+ while (V !== target) {
520
+ const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
521
+ const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
522
+ this.sourceOverride.set(k, next);
523
+ q = this._steadySteps(sub);
524
+ steps += q.steps;
525
+ iterations += q.iterations;
526
+ history.push({ continuation: next, converged: q.converged });
527
+ if (q.converged) {
528
+ V = next;
529
+ dV *= 1.5;
530
+ } else {
531
+ this.u.set(u1);
532
+ this.uLo.set(u1Lo);
533
+ this.termV.set(v1);
534
+ this.computeConcentrations();
535
+ dV /= 4;
536
+ if (Math.abs(dV) < 1e-6 * Math.abs(target - level)) return { converged: false, steps, iterations, history };
537
+ }
538
+ }
539
+ return { converged: true, steps, iterations, history, ramped: true };
540
+ } finally {
541
+ this.sourceOverride.delete(k);
542
+ if (hold !== undefined) this.sourceOverride.delete(1 - k);
543
+ this._refreshSources();
544
+ }
545
+ }
546
+
547
+ _steadySteps({ maxSteps = 80, tol = 1e-11 } = {}) {
548
+ const time = this.time;
549
+ const tau = this.slowestTime();
550
+ const direct = this._directSteady();
551
+ const giant = direct ? Infinity : 1e6 * tau;
552
+ let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
553
+ let triedDirect = 0;
554
+ let totalIter = 0, steps = 0, converged = false;
555
+ const history = [];
556
+ // What no row keeps (laws.js's unkept) a giant step keeps only by its storage term, which can
557
+ // fall below round-off against the fast species' conduction, and Newton then converges
558
+ // anywhere along it: a closed Y + Z, Y still in part of its stretch, converged 1e8× too large.
559
+ // So each such amount is checked after every step of dt ≥ giant, and a step that moved it
560
+ // fails.
561
+ const unkept = !direct && this.unkept.length > 0 ? this._unkeptAmounts() : null;
562
+ const drifted = () => {
563
+ const now = this._unkeptAmounts(), lost = now.findIndex(([a], j) => !(Math.abs(a - unkept[j][0]) <= 1e-6 * unkept[j][1]));
564
+ if (lost >= 0) this.drifted = now[lost][2];
565
+ return lost >= 0;
566
+ };
567
+ while (steps < maxSteps) {
568
+ steps++;
569
+ // Restore conserved amounts exactly before each huge step; the step then re-solves, so
570
+ // the final state satisfies every equation.
571
+ if (!direct && dt >= giant) this._renormalizeSpectators();
572
+ const snap = unkept && dt >= giant ? this._snapshot() : null;
573
+ let r = this.step(dt);
574
+ if (snap && r.converged && drifted()) {
575
+ this._restore(snap);
576
+ r = { ...r, converged: false, drifted: true };
577
+ }
578
+ totalIter += r.iterations;
579
+ history.push({ dt, converged: r.converged, iterations: r.iterations, ...(r.drifted ? { drifted: true } : {}) });
580
+ if (!r.converged && dt === Infinity && steps === 1) {
581
+ // A large change can overshoot Newton's usual damping: retry with a tighter limit.
582
+ r = this.step(dt, { maxStep: 3, maxIter: 80 });
583
+ totalIter += r.iterations;
584
+ history.push({ dt, converged: r.converged, iterations: r.iterations, maxStep: 3 });
585
+ }
586
+ if (!r.converged) {
587
+ // Pseudo-transient continuation: down from a slow time scale until a step converges,
588
+ // as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
589
+ dt = dt >= giant ? tau * 1e-6 : dt / 4;
590
+ grow = 2;
591
+ if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
592
+ continue;
593
+ }
594
+ if (dt === Infinity) {
595
+ converged = true; // the steady equations themselves were solved
596
+ break;
597
+ }
598
+ // Past the slowest diffusion time, the direct solve may already reach from here: try it
599
+ // once a decade (the state kept if it fails), rather than step on to the giant dt through
600
+ // steps that can fail for slow ions' sake.
601
+ if (direct && dt < giant && dt >= tau && dt >= 10 * triedDirect) {
602
+ triedDirect = dt;
603
+ const snap = this._snapshot();
604
+ const d = this.step(Infinity);
605
+ totalIter += d.iterations;
606
+ history.push({ dt: Infinity, converged: d.converged, iterations: d.iterations });
607
+ if (d.converged) {
608
+ converged = true;
609
+ break;
610
+ }
611
+ this._restore(snap);
612
+ }
613
+ if (dt >= giant && maxPotentialStep(this, this._diff()) < tol) {
614
+ converged = true;
615
+ if (dt > giant) {
616
+ // Finish at the base giant step, where the storage term pins amounts tightly.
617
+ this._renormalizeSpectators();
618
+ const f = this.step(giant);
619
+ totalIter += f.iterations;
620
+ converged = f.converged && !(unkept && drifted());
621
+ }
622
+ break;
623
+ }
624
+ if (dt < giant) {
625
+ dt *= grow; // ramping up after a failure
626
+ grow = Math.min(10, grow * 1.5);
627
+ if (dt > 1e6 * tau) dt = giant; // then the direct steady solve (or the giant step)
628
+ } else if (dt < 1e6 * giant) {
629
+ dt *= 10; // conserved amounts present and still moving: let dt keep growing (capped)
630
+ }
631
+ }
632
+ this.time = time;
633
+ this.history = []; // a steady solve isn't a trajectory: transients restart from here
634
+ this.dtNext = undefined;
635
+ if (converged) {
636
+ this.stretches.forEach((st, k) => {
637
+ if (!st.connected) return;
638
+ this.referenceAmounts[k] = this.amount(st);
639
+ this.boundaryIntake[k] = 0;
640
+ });
641
+ }
642
+ return { converged, steps, iterations: totalIter, history };
643
+ }
644
+
645
+ // The amounts no row keeps (unkept) that are of stretches and surfaces alone: each as [its value,
646
+ // the sum of its parts' sizes, the names of its parts].
647
+ _unkeptAmounts() {
648
+ const S = this.stretches.length, X = S + this.surfCols.length, { species, ports } = this.model;
649
+ return this.unkept.filter((w) => w.every((x, k) => k < X || x === 0)).map((w) => {
650
+ let a = 0, size = 0;
651
+ const names = [];
652
+ w.forEach((x, k) => {
653
+ if (x === 0) return;
654
+ const v = x * (k < S ? this.amount(this.stretches[k]) : this.surfaceAmount(k - S));
655
+ a += v;
656
+ size += Math.abs(v);
657
+ names.push(k < S ? species[this.stretches[k].species].name : ports[this.surfCols[k - S][0]].surface[this.surfCols[k - S][1]].name);
658
+ });
659
+ return [a, size, names];
660
+ });
661
+ }
662
+
663
+ // Huge steps pin each conserved amount only through a tiny storage term, so round-off can let
664
+ // it creep. Shift each spectator's level uniformly to restore its amount exactly (in one step
665
+ // for ideal statistics, where c ∝ e^η; by Newton on the shift otherwise).
666
+ _renormalizeSpectators() {
667
+ const { n, M, u } = this;
668
+ let changed = false;
669
+ this.stretches.forEach((st, k) => {
670
+ // A floating conductor holds its charge on its faces, not as an amount: the huge steps keep
671
+ // it, and there's nothing here to shift.
672
+ if (!st.spectator || this.model.materials[this.model.regions[st.regions[0]].material].conductor) return;
673
+ const want = this.referenceAmounts[k];
674
+ const ideal = this.nodeIdeal.subarray(st.nodes[0], st.nodes[1] + 1).every((v) => v === 1);
675
+ for (let it = 0; it < (ideal ? 1 : 30); it++) {
676
+ const now = this.amount(st);
677
+ if (!(now > 0) || now === want) return;
678
+ let shift;
679
+ if (ideal) shift = Math.log(want / now);
680
+ else {
681
+ let dA = 0;
682
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
683
+ const dc = this.nodeIdeal[g] ? this.c[g * n + st.species] : this.K[g * n * n + st.species * (n + 1)];
684
+ dA += this.model.grid.vol[g] * dc;
685
+ }
686
+ shift = (want - now) / dA;
687
+ if (!(Math.abs(shift) > 1e-16)) return;
688
+ }
689
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) u[this.blockOfNode[g] * M + 1 + st.species] += shift;
690
+ changed = true;
691
+ if (!ideal) this.computeConcentrations();
692
+ }
693
+ });
694
+ if (changed) this.computeConcentrations();
695
+ }
696
+
697
+ _diff() {
698
+ const d = this.delta; // reuse as scratch
699
+ const { fullOf } = this;
700
+ for (let j = 0; j < fullOf.length; j++) d[j] = this.u[fullOf[j]] - this.uPrev[fullOf[j]];
701
+ return d;
702
+ }
703
+
704
+ // Every stretch either reaches a contact or is kept by a law, and no law is left unkept: the
705
+ // steady equations can be solved directly (dt = ∞), with the laws in place of their rows. Else
706
+ // the steady solve goes by time steps, which keep every amount themselves.
707
+ _directSteady() {
708
+ return this.unkept.length === 0 && this.stretches.every((st) => st.connected || st.conserved);
709
+ }
710
+
711
+ // After a steady solve: each reaction that alone changes some combination nothing else feeds
712
+ // (see src/laws.js), whose total rate a steady state therefore sets to zero, but whose affinity has one
713
+ // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
714
+ // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
715
+ // port's slow couple at its starting composition, each converged without a word. (A face
716
+ // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
717
+ stuckReactions() {
718
+ const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
719
+ const passes = (k) => !noCurrent(this.terms[k]);
720
+ 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])))));
721
+ const needed = (rx) => {
722
+ const basis = conservedMoieties(this._structure(), { drives: true, skip: rx });
723
+ return basis.some((w) => !fed(w) && basis.skipped.some((row) => Math.abs(row.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9));
724
+ };
725
+ const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
726
+ const report = (where, as) => {
727
+ if (as.length === 0 || !(as.every((a) => a > 1e-6) || as.every((a) => a < -1e-6))) return;
728
+ out.push({ where, affinity: as.reduce((m, a) => (Math.abs(a) < Math.abs(m) ? a : m)) });
729
+ };
730
+ model.interfaces.forEach((itf, f) => {
731
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
732
+ itf.reactions.forEach((rx, q) => {
733
+ if (this.affinities.some((af) => af.rx === rx) || !needed(rx)) return;
734
+ report(`interfaces[${f}].reactions[${q}]`, [rx.part.reduce((a, p) => a - p.nu * eta(p.side ? gR : gL, 1 + p.i), rx.fixedA)]);
735
+ });
736
+ });
737
+ model.reactions.forEach((mrx, x) => {
738
+ const rx = this.rxs[x];
739
+ if (rx.generation || !needed(mrx)) return;
740
+ const as = [];
741
+ 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));
742
+ report(`bulkReactions[${x}]`, as);
743
+ });
744
+ model.ports.forEach((port, k) => {
745
+ port.reactions.forEach((rx, q) => {
746
+ if (!needed(rx)) return;
747
+ let aV = rx.fixedA;
748
+ for (const p of rx.part) if (p.side === 1) aV -= p.nu * this.portEta(port, p.i);
749
+ 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)));
750
+ });
751
+ });
752
+ return out;
753
+ }
754
+ }