driftlet 1.2.4 → 1.3.2

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