driftlet 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,505 @@
1
+ // Time steps: one implicit step of the device's equations (backward Euler, or variable-step
2
+ // BDF2), a step halved until it converges (advance), and adaptive stepping to a time with the
3
+ // local error controlled (integrate).
4
+ //
5
+ // CONTRACT
6
+ //
7
+ // These are functions of a Solver `s` (src/solver.js), which they read and write; its layout is
8
+ // in docs/internals.md. The Solver's own step, advance and integrate call them (the API, the
9
+ // steady solves' time stepping and the kit call those). What they use of it:
10
+ // s.newton(dt, opts) solves the equations at the step's end, from the state as it stands,
11
+ // with storage (c − c*)/dt against s.cOld, s.thOld, s.th0Old, s.gateOld,
12
+ // s.contactDStart, s.portQStart; leaves the state at its last iterate.
13
+ // s._snapshot() / s._restore(snap) the whole time state (u, uLo, termV, the start-of-step
14
+ // values, time, lastDt, history, the boundary intake, sourceTime).
15
+ // s._assembleBookkeeping(dt), s.computeConcentrations(), s._accumulateBoundaryIntake(dt, c),
16
+ // s._terminalCurrent(), s._probes(probes), s.fastestTime(), s.terms (drives, breakpoints).
17
+ //
18
+ // The state: u + uLo (Newton's unknowns, η and φ̂ in thermal units), termV (terminal voltages),
19
+ // time. The history: s.history, the accepted steps before this one, newest first, at most 3,
20
+ // each { t (its start), dt, u (its start state), c, th, th0, g (gates), D (contacts' D), Q
21
+ // (ports' charges) }: what BDF2 and the predictor need.
22
+ //
23
+ // step(s, dt, opts) → newton's result (+ bdf)
24
+ // One step from s.time to tEnd = s.time + dt (or s.landing exactly, where it's within 1e-9·dt
25
+ // of that). Backward Euler; BDF2 when opts.method is 'bdf2', a previous step exists and the
26
+ // ratio ω = dt/dt_prev ≤ 2: then c* = b1·c_n − b2·c_{n−1} (b1 = (1+ω)/a0, b2 = ω²/((1+ω)a0),
27
+ // a0 = (1+2ω)/(1+ω)) for every stored quantity, and the step is solved as backward Euler of
28
+ // length dt/a0, which is BDF2's storage term. Sources are read at tEnd from within the step
29
+ // (s.sourceTime, s.sourceBefore). If Newton fails: once more with the islands' rows in
30
+ // throughout (a steady solve's dt = ∞ only), and once more with every circuit law read by its
31
+ // change (see src/lawRows.js), each from the step's start. Accepted: the history gains the
32
+ // step, s.time = tEnd, s.lastDt the effective dt, the boundary intake accumulates. Failed:
33
+ // all as before the step (u, uLo, termV, the start-of-step values, atSteady, the sources'
34
+ // time, the concentrations).
35
+ //
36
+ // advance(s, dt, opts, depth) → { converged, iterations, history, substeps }
37
+ // step(dt); failing, two halves (each by advance), to a depth of 30.
38
+ //
39
+ // integrate(s, tEnd, opts) → { converged, done, steps, rejected, iterations, trace, stopped? }
40
+ // Steps to the absolute time tEnd (an error if not a number; NaN, or a time not ahead of
41
+ // s.time, does nothing and says why where it's likely a mistake). opts: tol (1e-3: the local
42
+ // error allowed per step, in thermal units, as errorNorm measures it), dtMax, budgetMs (wall
43
+ // time: stops early, done false, converged true), maxSteps, method ('bdf2' | 'be'), dt0,
44
+ // probes. The step size starts from s.dtNext (the last call's), else dt0, else 1e-4 of the
45
+ // interval, and is carried to the next call.
46
+ // - The first step (no history): backward Euler, its error the difference from two half
47
+ // steps, whose result is kept (and the first half step's point is in the trace).
48
+ // - Later: step with Newton started from an extrapolation through the history; BDF2 once two
49
+ // steps are in it; the error |u − pred|·scale against the explicit predictor (quadratic
50
+ // after BDF2 with three, linear otherwise), scale making it the step's own local error.
51
+ // - Accepted where error ≤ tol; the next h = h·factor, factor = 0.9·(tol/err)^(1/(p+1)) in
52
+ // [0.2, 2] (2 at err = 0). Rejected: the state restored, h·factor. A step that doesn't
53
+ // converge: h/4. Below the floor (1e-14 of tEnd, or 1e-2 of the fastest time scale), it
54
+ // stops, converged false, with a message.
55
+ // - A first step whose error doesn't shrink as it's cut (three tries within a factor 2 of
56
+ // each other): a jump at a boundary, self-similar in x/√t, so taken anyway.
57
+ // - Steps land exactly on the sources' breakpoints (and the history is cleared there: the
58
+ // slope jumps) and on tEnd; within round-off of tEnd (1e-10 of it and 1e-9 of the
59
+ // interval) the time snaps to it.
60
+ //
61
+ // Invariants (each says what a reviewer should be able to check here):
62
+ // 1. The state only moves by accepted steps: a rejected step, a failed one, or one stopped
63
+ // by the budget leaves u, uLo, termV, the start-of-step values, time and the history as
64
+ // they were before it.
65
+ // 2. time advances by exactly the steps accepted, lands exactly on each breakpoint and on
66
+ // tEnd, and never passes either.
67
+ // 3. BDF2's coefficients are those of variable-step BDF2 with ratio ω (exact for quadratics
68
+ // in t), applied to every stored quantity alike (c, coverages, gates, contacts' D, ports'
69
+ // charges), and only with ω ≤ 2 and a history entry of the previous step.
70
+ // 4. The error estimate is Milne's device as if the predictor's past states were exact: for
71
+ // BDF2, |u − pred|·Cc/(Cc + Cp) (Cc, Cp the corrector's and predictor's error constants at
72
+ // these step sizes); for backward Euler, h/(2h + h_prev). Through the integrator's own
73
+ // states (which satisfy the method) the true local error is larger: 1 + 1/(2ω) times
74
+ // backward Euler's estimate, and (2/9)/(2/11) ≈ 1.2 times BDF2's at equal steps, so a step
75
+ // accepted at tol errs up to ~1.3 tol (a cold review, on an RC circuit). Known, and left so
76
+ // far: backward Euler's low reading at short steps is what lets the controller step through
77
+ // a state left off its algebraic equations (see ROADMAP). The exponent is 1/(p + 1).
78
+ // 5. The history holds only accepted steps of the same trajectory: newest first, at most 3,
79
+ // cleared at a breakpoint, cut to the half step after a first step.
80
+ // 6. errorNorm counts every potential that carries state (φ̂ where it's defined, every
81
+ // present species' η, an electrode surface's coverages as η, gates' fractions), in
82
+ // thermal units, and nothing that doesn't.
83
+ // Tests: test/transient.test.js, and every test that calls advance(); tools/lawsweep.js (a
84
+ // settled transient as the steady state's reference).
85
+
86
+ import { nextBreakpoint } from './device.js';
87
+ import { SolverError } from './solver.js';
88
+
89
+ // Lagrange weights at x through the points s0, s1, s2.
90
+ function lagrange3(x, s0, s1, s2) {
91
+ return [((x - s1) * (x - s2)) / ((s0 - s1) * (s0 - s2)), ((x - s0) * (x - s2)) / ((s1 - s0) * (s1 - s2)), ((x - s0) * (x - s1)) / ((s2 - s0) * (s2 - s1))];
92
+ }
93
+
94
+ /**
95
+ * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
96
+ * Converged, the end time is t + dt; failed, wherever the halves that converged got to (the
97
+ * substeps counted are those of the last attempt's halves).
98
+ */
99
+ export function advance(s, dt, opts, depth = 0) {
100
+ const r = s.step(dt, opts);
101
+ if (r.converged || depth >= 30) return { ...r, substeps: 1 };
102
+ const a = s.advance(dt / 2, opts, depth + 1);
103
+ if (!a.converged) return a;
104
+ const b = s.advance(dt / 2, opts, depth + 1);
105
+ return {
106
+ converged: b.converged,
107
+ iterations: r.iterations + a.iterations + b.iterations,
108
+ history: b.history,
109
+ substeps: a.substeps + b.substeps,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * One time step of size dt. Backward Euler by default; with `method: 'bdf2'`, variable-step
115
+ * BDF2 once a previous step exists (and the step ratio is at most 2, for stability). On
116
+ * failure the state is restored.
117
+ *
118
+ * BDF2's storage term (a0 c − (1+ω) c_n + ω²/(1+ω) c_{n−1})/dt is written in backward-Euler
119
+ * form (c − c*)/(dt/a0), so assembly is shared: c* and the displacement histories replace
120
+ * the start-of-step values, and dt/a0 replaces dt.
121
+ */
122
+ export function step(s, dt, opts = {}) {
123
+ // What the step overwrites before Newton runs, for a failed one to put back: the Solution read
124
+ // after it takes its contacts' fluxes against the start-of-step values (a host emptied by its
125
+ // current read its contact's Li⁺ flux 8× off at the same state).
126
+ const was = { cOld: Float64Array.from(s.cOld), thOld: Float64Array.from(s.thOld), th0Old: Float64Array.from(s.th0Old), gateOld: Float64Array.from(s.gateOld), contactDStart: s.contactDStart, portQStart: s.portQStart, atSteady: s.atSteady };
127
+ s.atSteady = false;
128
+ const prev = s.history[0];
129
+ const w = prev ? dt / prev.dt : 0;
130
+ const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
131
+ s.uPrev.set(s.u);
132
+ s.uPrevLo.set(s.uLo);
133
+ s.termVPrev.set(s.termV);
134
+ s.computeConcentrations();
135
+ const cN = Float64Array.from(s.c), thN = Float64Array.from(s.th), th0N = Float64Array.from(s.th0);
136
+ const gN = Float64Array.from(s.gateList, (gt) => s.u[gt.o] + s.uLo[gt.o]);
137
+ // Contact displacement before the step: as it was at the end of the previous step (under
138
+ // the parameters then), so a gate-voltage change shows up as displacement current.
139
+ if (!s.contactDEnd || !s.portQEnd) {
140
+ s._assembleBookkeeping(dt);
141
+ s.contactDEnd = { ...s.contactD };
142
+ s.portQEnd = Float64Array.from(s.portQ);
143
+ }
144
+ const DN = { ...s.contactDEnd }, QN = Float64Array.from(s.portQEnd);
145
+ if (opts.guess) setGuess(s, opts.guess); // start Newton from a predicted state
146
+ let dtEff = dt;
147
+ if (bdf) {
148
+ const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
149
+ const { cOld } = s;
150
+ for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
151
+ for (let k = 0; k < thN.length; k++) s.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
152
+ for (let k = 0; k < th0N.length; k++) s.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
153
+ for (let k = 0; k < gN.length; k++) s.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
154
+ s.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
155
+ s.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
156
+ dtEff = dt / a0;
157
+ } else {
158
+ s.cOld.set(cN);
159
+ s.thOld.set(thN);
160
+ s.th0Old.set(th0N);
161
+ s.gateOld.set(gN);
162
+ s.contactDStart = DN;
163
+ s.portQStart = QN;
164
+ }
165
+ // Implicit: sources at the step's end, as seen from within the step (before any jump there).
166
+ // A step landing on a breakpoint ends on it exactly.
167
+ const tEnd = s.landing !== undefined && Math.abs(s.time + dt - s.landing) <= 1e-9 * dt ? s.landing : s.time + dt;
168
+ s.sourceTime = s.steady ? s.steadyAt : tEnd;
169
+ s.sourceBefore = !s.steady;
170
+ s.combining = true;
171
+ s.lawForm = new Map(); // (see circuitLaws in src/lawRows.js)
172
+ let result;
173
+ try {
174
+ result = s.newton(dtEff, opts);
175
+ if (!result.converged && dtEff === Infinity && s.steady && s.islands.length > 0) {
176
+ // Plain solves that don't converge may be lost along an island's level (two regions
177
+ // conducting 1e20 times less hold it): once more from the start, with the islands'
178
+ // summed rows in throughout (see newton).
179
+ s.u.set(s.uPrev);
180
+ s.uLo.set(s.uPrevLo);
181
+ s.termV.set(s.termVPrev);
182
+ if (opts.guess) setGuess(s, opts.guess);
183
+ const again = s.newton(dtEff, { ...opts, islands: true });
184
+ result = { ...again, iterations: result.iterations + again.iterations };
185
+ }
186
+ if (!result.converged && Number.isFinite(dtEff) && [...s.lawForm.values()].some((v) => !v)) {
187
+ // A terminal read by its flux whose response is lost to round-off (a floating gate's
188
+ // charging, below its metal's G Δη): once more, read by the change (see circuitLaws in
189
+ // src/lawRows.js).
190
+ s.u.set(s.uPrev);
191
+ s.uLo.set(s.uPrevLo);
192
+ s.termV.set(s.termVPrev);
193
+ if (opts.guess) setGuess(s, opts.guess);
194
+ for (const cs of s.lawForm.keys()) s.lawForm.set(cs, true);
195
+ const again = s.newton(dtEff, opts);
196
+ result = { ...again, iterations: result.iterations + again.iterations };
197
+ }
198
+ } finally {
199
+ s.combining = false;
200
+ }
201
+ result.bdf = bdf;
202
+ if (result.converged) {
203
+ s.history.unshift({ t: s.time, dt, u: Float64Array.from(s.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
204
+ if (s.history.length > 3) s.history.length = 3;
205
+ s.time = tEnd;
206
+ s.lastDt = dtEff;
207
+ s.contactDOld = s.contactDStart;
208
+ s.portQOld = s.portQStart;
209
+ s._accumulateBoundaryIntake(dtEff, cN);
210
+ // (In time, the state is no longer the steady solve's that a continuation would start from.)
211
+ if (!s.steady) {
212
+ s.solvedV = undefined;
213
+ s.stepped = true;
214
+ }
215
+ } else {
216
+ s.u.set(s.uPrev);
217
+ s.uLo.set(s.uPrevLo);
218
+ s.termV.set(s.termVPrev);
219
+ s.cOld.set(was.cOld);
220
+ s.thOld.set(was.thOld);
221
+ s.th0Old.set(was.th0Old);
222
+ s.gateOld.set(was.gateOld);
223
+ s.contactDStart = was.contactDStart;
224
+ s.portQStart = was.portQStart;
225
+ s.atSteady = was.atSteady;
226
+ s.sourceTime = s.steady ? s.steadyAt : s.time;
227
+ s.computeConcentrations();
228
+ }
229
+ s.sourceBefore = false;
230
+ return result;
231
+ }
232
+
233
+ // Newton's unknowns set to a guess (e.g. extrapolated from the history). Only its unknowns: the
234
+ // other slots never change (see _activeSlots), and among them is a gauge, an insertion host's
235
+ // φ̂, that nothing pulls back. Extrapolated through steps that grow, it wandered off
236
+ // geometrically, 147 to 8000 thermal units in a battery's first 100 s and 1e11 by 1e5 s, where
237
+ // its species' levels (η − zφ̂, summed back by the host's statistics) lost their digits to it.
238
+ function setGuess(s, guess) {
239
+ const { u, uLo } = s;
240
+ for (const k of s.fullOf) {
241
+ u[k] = guess[k];
242
+ uLo[k] = 0;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
248
+ * estimated against an explicit predictor through the previous states, and controlled to
249
+ * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
250
+ * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
251
+ * animation can call it once per frame; the step size carries over between calls.
252
+ */
253
+ export function integrate(s, tEnd, opts = {}) {
254
+ // tEnd is an absolute time. Not a number at all is an error; a NaN (a first animation frame's
255
+ // clock, or a time read from a field that doesn't exist) does nothing, and says so.
256
+ const absolute = `tEnd is the time to reach in s, absolute (the device is at t = ${s.time}, which a solution gives as its time)`;
257
+ if (typeof tEnd !== 'number') throw new SolverError(`advance(tEnd): ${absolute}; got ${tEnd}`);
258
+ if (Number.isNaN(tEnd)) return { converged: true, done: false, steps: 0, rejected: 0, iterations: 0, trace: { t: [], current: [], voltage: [] }, stopped: `advance(NaN) did nothing: ${absolute}` };
259
+ const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
260
+ const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
261
+ const start = clock();
262
+ const sample = s._probes(opts.probes);
263
+ const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
264
+ let steps = 0, rejected = 0, iterations = 0, failed = false;
265
+ // Already there (an animation frame with no time to add): nothing to do, and the step size
266
+ // carried to the next call stays as it was. A time already passed, or reached again outside
267
+ // an animation, is most likely advance(dt) read as a duration: say so.
268
+ if (!(tEnd > s.time)) {
269
+ const relative = tEnd < s.time || (s.time > 0 && opts.budgetMs === undefined);
270
+ return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace, ...(relative ? { stopped: `advance(${tEnd}) did nothing: ${absolute}. To run for a further dt, advance(sol.time + dt)` } : {}) };
271
+ }
272
+ const t0 = s.time;
273
+ // A cold start whose floating double layers start uncharged far from the equilibrium of an ion
274
+ // link beside them (a capacitive contact or port passing K⁺, its outside level set for the
275
+ // charged steady state): the link closes the gap in the first instant, which can be far below
276
+ // any step's reach. Noted here, for a failure to say what to do instead.
277
+ const violent = s.history.length === 0 && !s.stepped ? violentStart(s) : [];
278
+ let dt = s.dtNext ?? opts.dt0 ?? (tEnd - s.time) * 1e-4;
279
+ if (!(dt > 0)) dt = (tEnd - s.time) * 1e-4;
280
+ // The shortest step worth trying: round-off relative to the time, or the device's fastest
281
+ // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
282
+ const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), s.fastestTime() * 1e-2);
283
+ const pred = new Float64Array(s.u.length), guess = new Float64Array(s.u.length);
284
+ const firstErrs = []; // the first step's error at each size tried (see below)
285
+ const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
286
+ while (s.time < tEnd) {
287
+ if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
288
+ // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
289
+ // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
290
+ // strictly neutral device, fails outright. Round-off of the time and negligible beside the
291
+ // interval asked for: by the time alone, a GITT pulse of 0.01 s at t = 1e9 s was skipped
292
+ // whole (the time moved, nothing else did), and from 1e10 s 17% of a pulse train's charge.
293
+ if (tEnd - s.time <= 1e-10 * Math.abs(tEnd) && tEnd - s.time <= 1e-9 * (tEnd - t0)) {
294
+ s.time = tEnd;
295
+ break;
296
+ }
297
+ const remaining = tEnd - s.time;
298
+ let h = Math.min(dt, dtMax);
299
+ let clamped = h >= remaining * (1 - 1e-9);
300
+ if (clamped) h = remaining;
301
+ // Land on the waveforms' breakpoints, where a source's slope jumps.
302
+ const tb = s.terms.reduce((m, t) => Math.min(m, nextBreakpoint(t.drive.src, s.time)), Infinity);
303
+ const atBreak = tb - s.time <= h * (1 + 1e-9) && tb <= tEnd; // (one just past tEnd is the next call's)
304
+ if (atBreak) {
305
+ h = tb - s.time;
306
+ clamped = true;
307
+ }
308
+ s.landing = atBreak ? tb : clamped ? tEnd : undefined;
309
+ const snap = s._snapshot();
310
+ let err, p, half = null;
311
+ if (s.history.length > 0) firstErrs.length = 0;
312
+ if (s.history.length === 0) {
313
+ // First step: backward Euler, checked against two half steps (whose result is kept).
314
+ const full = s.step(h);
315
+ iterations += full.iterations;
316
+ if (full.converged) {
317
+ const uFull = Float64Array.from(s.u);
318
+ s._restore(snap);
319
+ const a = s.step(h / 2);
320
+ // The first half step is kept too, so it goes in the trace (a jump's charge is in it).
321
+ half = a.converged ? { t: s.time, current: s._terminalCurrent(), voltage: s.termV[1] - s.termV[0], probes: sample?.read() } : null;
322
+ const b = a.converged ? s.step(h / 2) : a;
323
+ iterations += a.iterations + (b === a ? 0 : b.iterations);
324
+ if (b.converged) {
325
+ err = errorNorm(s, uFull);
326
+ p = 1;
327
+ }
328
+ }
329
+ if (err === undefined) {
330
+ s._restore(snap);
331
+ rejected++;
332
+ dt = h / 4;
333
+ if (dt < floor) { failed = true; break; }
334
+ continue;
335
+ }
336
+ } else {
337
+ // (Order 2 once three consistent states can check it.)
338
+ const r = s.step(h, { method: s.history.length >= 2 ? method : 'be', guess: extrapolate(s, guess, h) });
339
+ iterations += r.iterations;
340
+ if (!r.converged) {
341
+ s._restore(snap);
342
+ rejected++;
343
+ dt = h / 4;
344
+ if (dt < floor) { failed = true; break; }
345
+ continue;
346
+ }
347
+ p = predict(s, pred, r.bdf);
348
+ err = errorNorm(s, pred) * p.scale;
349
+ p = p.order;
350
+ }
351
+ // A first step whose error doesn't shrink with it: a jump in a level held at a boundary
352
+ // (a bath's, after a voltage step) starts a profile self-similar in x/√t, so a first step
353
+ // of any length errs alike, down to the grid's own diffusion time, below Newton's reach.
354
+ // Three tries in a row, each within a factor 2 of the last, say so: the error is the
355
+ // jump's, not the step's. Take the step (backward Euler damps it as it should); the next is
356
+ // sized from its error as any other. (Counted within one call: under a frame's budget, a
357
+ // first step cut across calls isn't seen to stall.)
358
+ if (half && err > tol) firstErrs.push(err);
359
+ const jump = half && firstErrs.length >= 3 && firstErrs.slice(-3).every((e, j, a) => j === 0 || (e > 0.5 * a[j - 1] && e < 2 * a[j - 1]));
360
+ if (err > tol && !jump) {
361
+ s._restore(snap);
362
+ rejected++;
363
+ dt = h * factor(err, p);
364
+ continue;
365
+ }
366
+ steps++;
367
+ // The state the first step started from needn't satisfy the algebraic equations (φ in a
368
+ // strictly neutral region, an interface's unknowns, after a start or a jump), and they
369
+ // jump in the first instant; a predictor through it would see that jump as error on every
370
+ // step after. The history starts from the half step.
371
+ if (half) s.history.length = 1;
372
+ dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
373
+ if (atBreak) s.history = []; // the solution's slope jumps here: restart the order
374
+ if (half) {
375
+ trace.t.push(half.t);
376
+ trace.current.push(half.current);
377
+ trace.voltage.push(half.voltage);
378
+ if (sample) sample.push(half.probes);
379
+ }
380
+ trace.t.push(s.time);
381
+ trace.current.push(s._terminalCurrent());
382
+ trace.voltage.push(s.termV[1] - s.termV[0]);
383
+ if (sample) sample.push(sample.read());
384
+ }
385
+ s.dtNext = dt;
386
+ s.landing = undefined;
387
+ // Stopped short, other than on the caller's wall-time budget: say where, and why.
388
+ let stopped;
389
+ const at = `advance stopped at t = ${s.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
390
+ // (What usually does it, most likely first. Round 11's agents read the old list, which led
391
+ // with a drive's jump, as blaming a modest current pulse: a host emptied at its surface, and
392
+ // an electrode at open circuit with no double layer, held only by a slow reaction.)
393
+ const unbalanced = s.history.length === 0 && s.time === t0 ? (s.unbalanced ?? []).map((r) => s.model.regions[r].name ?? `regions[${r}]`) : [];
394
+ if (failed && unbalanced.length > 0 && s.time === t0)
395
+ stopped = `${at}: the start can't be neutral in ${unbalanced.join(', ')}, strictly neutral (ε = 0): the amounts its c0 gives leave a net charge that no species held at a contact's level can balance, and a step can't move a stored amount at once. Give c0 that balances (with any fixed charge), or leave out of c0 the species a contact feeds, so that they start at its level.`;
396
+ else if (failed && violent.length > 0)
397
+ stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. The start is violent: ${violent.join('; ')}. A double layer starts uncharged, and what passes beside it closes such a gap in the first instant, faster than any step can follow. For a start in equilibrium, solve() first (the steady state at the starting drives), then change what the experiment changes, then advance(). Where solve() fails from there too, hold that terminal at a voltage, solve(), then let it float (set its I: 0): it keeps the state the held one gave it.`;
398
+ else if (failed) stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. Look for: a host or surface driven to its limit (emptied or filled at its surface by a current it can no longer pass); an electrode at open circuit held only by a slow reaction, with no double-layer capacitance to keep its level (give its face one: phi capacitive); a drive's jump too big for one step (ramp it); a driven current the device cannot keep passing (a diode's reverse recovery past its storage time: stop there); or a device the steady solves also find hard.`;
399
+ else if (s.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
400
+ return { converged: !failed, done: s.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
401
+ }
402
+
403
+ // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
404
+ // the two most recent history entries: a starting guess for Newton. (In offsets built from the
405
+ // steps' own lengths, never differences of absolute times: a second into a run, after a jump
406
+ // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
407
+ function extrapolate(s, out, h) {
408
+ const [e0, e1] = s.history;
409
+ const u = s.u;
410
+ if (!e0) return undefined;
411
+ if (!e1) {
412
+ const l = h / e0.dt;
413
+ for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
414
+ return out;
415
+ }
416
+ const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
417
+ for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
418
+ return out;
419
+ }
420
+
421
+ // Explicit predictor at the new time through the previous states (quadratic after BDF2,
422
+ // linear after backward Euler), and the factor turning |u − pred| into the local error.
423
+ function predict(s, pred, bdf) {
424
+ const [e0, e1, e2] = s.history;
425
+ const h = e0.dt;
426
+ if (!e1) {
427
+ pred.set(e0.u);
428
+ return { scale: 1, order: 1 };
429
+ }
430
+ const hp = e1.dt;
431
+ if (bdf && e2) {
432
+ const hpp = e2.dt;
433
+ // Lagrange through the three states before at the new time (offsets as in extrapolate).
434
+ const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
435
+ for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
436
+ const w = h / hp;
437
+ const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
438
+ const Cp = h * (h + hp) * (h + hp + hpp);
439
+ return { scale: Cc / (Cc + Cp), order: 2 };
440
+ }
441
+ const l0 = (h + hp) / hp, l1 = 1 - l0;
442
+ for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
443
+ // Backward Euler's error against a linear predictor; after a BDF2 step without enough
444
+ // history this overestimates, which is safe.
445
+ return { scale: bdf ? 1 : h / (2 * h + hp), order: 1 };
446
+ }
447
+
448
+ // Largest difference from ref among the potentials that carry state, in thermal units.
449
+ function errorNorm(s, ref) {
450
+ const { n, M, u } = s;
451
+ let mx = 0;
452
+ for (let g = 0; g < s.nNodes; g++) {
453
+ const b = s.blockOfNode[g];
454
+ if (!s.phiUndefined[g]) mx = Math.max(mx, Math.abs(u[b * M] - ref[b * M]));
455
+ for (let i = 0; i < n; i++) {
456
+ if (s.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
457
+ }
458
+ const k = s.surfPort[g]; // an electrode surface's coverages, as η
459
+ if (k >= 0) for (let q = 0; q < s.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(u[b * M + 1 + n + q] - ref[b * M + 1 + n + q]));
460
+ }
461
+ for (const { o } of s.gateList) mx = Math.max(mx, Math.abs(u[o] - ref[o])); // (a fraction: 1e-3 of m³ is 0.3% of P)
462
+ return mx;
463
+ }
464
+
465
+ // The floating double layers a cold start leaves far (0.1 V or more) from the equilibrium of a
466
+ // path beside them: a contact with a capacitive φ, or a port with a capacitance, at I = 0 or
467
+ // behind a resistance, with a link holding a charged species at an outside level V + offset,
468
+ // or (a port) a reaction that takes its carrier. Each as words for a message.
469
+ export function violentStart(s) {
470
+ const out = [], { M, n, z, VT, model } = s;
471
+ const check = (what, k, links, g) => {
472
+ for (let i = 0; i < n; i++) {
473
+ const l = links[i];
474
+ if (z[i] === 0 || !['conductance', 'equilibrium'].includes(l.type) || !s.present[g * n + i]) continue;
475
+ const gap = (VT * s.u[s.blockOfNode[g] * M + 1 + i]) / z[i] - (s.termV[k] + (l.offset ?? 0));
476
+ if (Math.abs(gap) >= 0.1) out.push(`${what}'s ${model.species[i].name} link is ${Math.abs(gap).toFixed(2)} V from equilibrium`);
477
+ }
478
+ };
479
+ for (const k of s.floating) {
480
+ const t = s.terms[k];
481
+ if (t.kind === 'contact') {
482
+ const ct = model.contacts[t.side];
483
+ if (ct.phi.type === 'capacitive') check(`contact ${t.side}`, k, ct.species, t.side === 'left' ? 0 : s.nNodes - 1);
484
+ continue;
485
+ }
486
+ const port = model.ports[t.index];
487
+ if (!port.capacitance) continue;
488
+ check(`port ${port.name}`, k, port.species, port.nodes[0]);
489
+ // Its reactions at its first node: the affinity a = fixedA − Σ ν η, in volts by how it moves
490
+ // with the port's voltage (−Σ ν z/V_T over the carrier's part).
491
+ const b = s.blockOfNode[port.nodes[0]];
492
+ port.reactions.forEach((rx, j) => {
493
+ let a = rx.fixedA, slope = 0;
494
+ for (const p of rx.part) {
495
+ if (p.side === 1) {
496
+ a -= p.nu * s.portEta(port, p.i);
497
+ slope -= (p.nu * z[p.i]) / VT;
498
+ } else a -= p.nu * s.u[b * M + (p.side === 2 ? 1 + n + p.s : 1 + p.i)];
499
+ }
500
+ const gap = slope !== 0 ? Math.abs(a / slope) : 0;
501
+ if (gap >= 0.1) out.push(`port ${port.name}'s reactions[${j}] is ${gap.toFixed(2)} V from equilibrium`);
502
+ });
503
+ }
504
+ return out;
505
+ }
package/src/types.js CHANGED
@@ -317,6 +317,9 @@
317
317
  * @typedef {object} ImpedanceResult
318
318
  * @property {Float64Array} f frequencies, Hz
319
319
  * @property {{ re: Float64Array, im: Float64Array }} Z impedance δV/δI at the terminal, I into the device, Ω·m²
320
+ * @property {Float64Array} relError each Z's estimated error, relative to |Z| (on Re Z and Im Z alike): the current's round-off
321
+ * and the solves' residuals; cautious (it can exceed the true error many times). Over 1%, or over 10·|Re Z|/|Z|,
322
+ * a warning says so
320
323
  * @property {{ phi: ComplexProfile, mu: Record<string, ComplexProfile>, c: Record<string, ComplexProfile> }[]} [profiles]
321
324
  * per frequency, per unit excitation (volt, or A/m² in current mode)
322
325
  * @property {string[]} warnings the steady state's warnings, which hold for its impedance too
@@ -0,0 +1,18 @@
1
+ export class Assembly {
2
+ _j(rb: any, rs: any, cb: any, cs: any, v: any): void;
3
+ /** Assemble residual and Jacobian for a backward-Euler step of size dt. */
4
+ assemble(dt: any): void;
5
+ dtNow: any;
6
+ transformed: boolean;
7
+ _replaceRow(b: any, rs: any): void;
8
+ _assembleDifference(dt: any): {
9
+ rest: any;
10
+ lin: DifferenceTerms;
11
+ };
12
+ sysRest: any;
13
+ diffTerms: DifferenceTerms;
14
+ sys: any;
15
+ lin: DifferenceTerms;
16
+ _assembleBookkeeping(dt: any): void;
17
+ }
18
+ import { DifferenceTerms } from './difference.js';
@@ -2,3 +2,12 @@
2
2
  export function bernoulli(x: any): number;
3
3
  /** dB/dx, with B'(0) = −1/2. */
4
4
  export function bernoulliDerivative(x: any): number;
5
+ /**
6
+ * A Scharfetter–Gummel flux N = −g·B(d)·c_L·expm1(Δη) and its derivatives, into `out`: [N, ∂N/∂d,
7
+ * g·B(d)·c_L (= ∂N/∂η_L), g·B(d)·c_L·e^{Δη} (= −∂N/∂η_R), −g·expm1(Δη)·(B(d) + B′(d)) (the
8
+ * non-ideal kernel's ∂N/∂ζ_L per unit K_L)]. d is the drift, Δη the difference of levels, so
9
+ * that c_R = c_L e^{Δη−d}. From d = SG_FAR on, the same flux from the downstream end, N =
10
+ * g·B(−d)·c_R·expm1(−Δη), whose factors stay finite; ∂N/∂d = N·B′(d)/B(d).
11
+ */
12
+ export function sgFlux(g: any, d: any, deta: any, cL: any, cR: any, out: any): any;
13
+ export const SG_FAR: 300;
@@ -0,0 +1,24 @@
1
+ export function gateRate(law: any, V: any): number[];
2
+ export class BoundaryTerms {
3
+ _portRate(port: any, rx: any, g: any): number;
4
+ _face(f: any): void;
5
+ _gateSlot(f: any, q: any): any;
6
+ _gateVoltage(gt: any): number;
7
+ _portVoltage(k: any, g: any): number;
8
+ _faceVoltage(f: any): number;
9
+ _faceGates(f: any, bf: any, bL: any, bR: any): void;
10
+ _faceReaction(rx: any, k: any, f: any, bf: any, bL: any, bR: any, Af: any): void;
11
+ _liveTerms(): any;
12
+ _allTerms: any;
13
+ _terminals(dt: any): void;
14
+ _circuit(k: any): void;
15
+ _captureRow(b: any, s: any, w: any, into: any, k?: number): any;
16
+ _port(port: any, flux: any, k: any, dt: any, holds: any): void;
17
+ _portReaction(port: any, rx: any, area: any, rates: any, flux: any, k: any): void;
18
+ _portGates(port: any, k: any, dt: any): void;
19
+ _open(gated: any, x: any): number;
20
+ _dOpen(gated: any, q: any, p: any, x: any): number;
21
+ _portCapacitance(port: any, k: any, dt: any): void;
22
+ _surfaceStorage(port: any, dt: any): void;
23
+ _contact(side: any, dt: any): void;
24
+ }
@@ -0,0 +1,53 @@
1
+ export class SmallSignal {
2
+ /**
3
+ * Small-signal impedance about the current (steady) state, seen at one terminal. Linearising
4
+ * gives (J + iωM)·δx + Σ B_k δV_k = rhs for the grid's rows, and (C_k + iωC′_k)·δx +
5
+ * (∂I_k/∂V_k)·δV_k = δI_set,k for each floating terminal's circuit row, with J the steady
6
+ * Jacobian and M the storage part (from J(dt) = J + M/dt), B_k = ∂res/∂V_k and C_k = ∂I_k/∂x.
7
+ * At the measured terminal, a held voltage is perturbed (rhs = −B δV) and its current read
8
+ * from C, or a driven current is perturbed and its voltage solved for. The other terminals
9
+ * keep their drives: held ones at AC ground, driven ones at AC open circuit.
10
+ * Z = δV/δI with I into the device (positive real part for a passive device).
11
+ * @param {ArrayLike<number>} frequencies Hz
12
+ */
13
+ impedance(frequencies: ArrayLike<number>, opts: any): {
14
+ f: Float64Array<ArrayBuffer>;
15
+ Z: {
16
+ re: Float64Array<any>;
17
+ im: Float64Array<any>;
18
+ };
19
+ relError: Float64Array<any>;
20
+ };
21
+ allTerminals: any;
22
+ _impedance(frequencies: any, { profiles, terminal }?: {
23
+ profiles?: boolean;
24
+ terminal?: string;
25
+ }): {
26
+ f: Float64Array<ArrayBuffer>;
27
+ Z: {
28
+ re: Float64Array<any>;
29
+ im: Float64Array<any>;
30
+ };
31
+ relError: Float64Array<any>;
32
+ };
33
+ sourceTime: any;
34
+ contactDStart: any;
35
+ portQStart: Float64Array<ArrayBuffer>;
36
+ lawForm: Map<any, boolean>;
37
+ _regionShift(yr: any, yi: any): Float64Array<any>[];
38
+ _regionEdgeRows(): Uint8Array<any>;
39
+ _currentCuts(): {
40
+ s: any;
41
+ idx: any[];
42
+ g: any[];
43
+ d: any[];
44
+ }[];
45
+ _smallSignalProfiles(xr: any, xi: any): {
46
+ phi: {
47
+ re: Float64Array<any>;
48
+ im: Float64Array<any>;
49
+ };
50
+ mu: {};
51
+ c: {};
52
+ };
53
+ }
@@ -0,0 +1,7 @@
1
+ export function maxPotentialStep(s: any, delta: any, deltaV?: any): number;
2
+ export function newton(s: any, dt: any, { maxIter, tol, maxStep, islands }?: {
3
+ maxIter?: number;
4
+ tol?: number;
5
+ maxStep?: number;
6
+ islands?: boolean;
7
+ }): any;