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/README.md +1 -1
- package/docs/conventions.md +8 -3
- package/docs/device.md +38 -9
- package/docs/internals.md +33 -14
- package/docs/numerics.md +65 -15
- package/docs/reliability.md +25 -16
- package/docs/statistics.md +13 -0
- package/llms.txt +11 -8
- package/package.json +1 -1
- package/src/assemble.js +206 -0
- package/src/bernoulli.js +32 -0
- package/src/boundaries.js +795 -0
- package/src/describe.js +2 -0
- package/src/device.js +32 -6
- package/src/impedance.js +857 -0
- package/src/index.js +25 -1
- package/src/newton.js +605 -0
- package/src/regions.js +1009 -0
- package/src/solution.js +24 -2
- package/src/solver.js +178 -3643
- package/src/start.js +508 -0
- package/src/steady.js +817 -0
- package/src/timeSteps.js +17 -2
- package/src/types.js +3 -0
- package/types/assemble.d.ts +18 -0
- package/types/bernoulli.d.ts +9 -0
- package/types/boundaries.d.ts +24 -0
- package/types/impedance.d.ts +53 -0
- package/types/newton.d.ts +7 -0
- package/types/regions.d.ts +61 -0
- package/types/solver.d.ts +14 -220
- package/types/start.d.ts +14 -0
- package/types/steady.d.ts +81 -0
- package/types/types.d.ts +6 -0
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
|
+
}
|