driftlet 1.2.3 → 1.2.4

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 CHANGED
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
56
56
  ```
57
57
 
58
58
  Or straight from a CDN in a page, pinned to a version:
59
- `https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
+ `https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/index.js` (and `src/kit.js`, `src/plot.js`).
60
60
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
61
61
  [`llms.txt`](llms.txt).
62
62
 
package/docs/device.md CHANGED
@@ -662,7 +662,13 @@ voltage, the port is a potentiostat. At `I: 0` (or with nothing else to carry cu
662
662
  reactions settle at the mixed potential, the corrosion potential where anodic and cathodic
663
663
  currents cancel over the window, while each node can be a net anode or a net cathode; a floating
664
664
  one starts there, for the start's composition (with a double layer, it starts uncharged and
665
- charges toward it). An electrode that's the device's only path for current is at `I: 0` whatever
665
+ charges toward it). A floating double layer that starts far from the equilibrium of a path
666
+ beside it (an ion link whose outside level was set for the charged steady state, a fast
667
+ reaction) closes the gap in the first instant, which a transient may not be able to follow: it
668
+ then stops saying so. For a start in equilibrium, `solve()` first, then change what the
669
+ experiment changes, then `advance()`; where the solve can't cross the gap either (it says so too),
670
+ hold the electrode at a voltage, `solve()`, then let it float (`I: 0`), which keeps the coverages
671
+ the held state gave it. An electrode that's the device's only path for current is at `I: 0` whatever
666
672
  its drive: hold it at `V: 0`, which also fixes the level the water's φ is read against. Per volume of the window a rate is
667
673
  `rates[q][j] * area[j]`, and per area of a film's floor (thickness h, `area` 1/h) the rate itself.
668
674
 
package/docs/internals.md CHANGED
@@ -15,6 +15,7 @@ them is wrong: fix both, with a test, in the same commit.
15
15
  | --- | --- |
16
16
  | `src/laws.js` | yes: the steady system's conservation laws |
17
17
  | `src/lawRows.js` | yes: the laws written into the Newton system, and the bordered solve |
18
+ | `src/timeSteps.js` | yes: one step (backward Euler, BDF2), advance's halving, integrate's error control |
18
19
  | `src/difference.js` | header: the dilute kernels in difference form |
19
20
  | `src/blockTridiagonal.js` | header: the block Thomas solver |
20
21
  | `src/statistics.js` | header: each model's `evaluate` / `invert` |
@@ -69,8 +70,9 @@ By method, in file order:
69
70
  kernels it calls (nodes, segments, faces, bulk reactions, ports, contacts, charge rows),
70
71
  `_assembleDifference` (with `src/difference.js`).
71
72
  - **Newton:** `newton`, `_refine`, `_equilibrate`, `_kirchhoff`, `_noteConditioning`.
72
- - **Time steps:** `advance`, `step`, `integrate` (BDF with error control, breakpoints from the
73
- sources), `_predict`, `_errorNorm`, `_snapshot` / `_restore`.
73
+ - **Time steps** are `src/timeSteps.js` (`step`, `advance`, `integrate`, the predictor and the
74
+ error norm), which the Solver's own `step`, `advance` and `integrate` call; `_snapshot` /
75
+ `_restore`, `_warmFrom` and `_probes`, which the API uses too, stay in the solver.
74
76
  - **Small signal:** `impedance` and the helpers after it.
75
77
  - **Bookkeeping:** `segmentFluxes`, `faceOneWay`, `_assembleBookkeeping`, the contacts' intake.
76
78
  - **Steady solves:** `solveSteady`, `_steadyFromHere`, the continuations (floating, generation,
package/llms.txt CHANGED
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
30
30
  From a CDN, pin the version you tested against:
31
31
 
32
32
  ```js nocheck
33
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/plot.js';
33
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/index.js';
34
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/kit.js';
35
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/plot.js';
36
36
  ```
37
37
 
38
38
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
219
219
  <span id="readout"></span>
220
220
  <div id="figure"></div>
221
221
  <script type="module">
222
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.3/src/plot.js';
222
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/index.js';
223
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/kit.js';
224
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.4/src/plot.js';
225
225
 
226
226
  const def = build({
227
227
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
5
5
  "type": "module",
6
6
  "exports": {
package/src/solution.js CHANGED
@@ -287,7 +287,14 @@ export function makeSolution(solver, result = {}) {
287
287
  : '')),
288
288
  );
289
289
  }
290
- if (result.converged === false && !result.stopped && !un && !ch && !(cond && cond.digits > 12)) {
290
+ if (result.converged === false && !result.stopped && !un && !ch && solver.violent?.length > 0) {
291
+ // A cold start far from where a floating double layer's neighbours balance (an electrode's
292
+ // reactions with its starting coverages, an ion link): Newton has to cross that gap at once.
293
+ sol.warnings.push(
294
+ `the solve did not converge from a start far from equilibrium: ${solver.violent.join('; ')}, its double layer uncharged. ` +
295
+ 'Hold that terminal at a voltage first, solve(), then let it float (set its I: 0): it keeps the state the held one gave it.',
296
+ );
297
+ } else if (result.converged === false && !result.stopped && !un && !ch && !(cond && cond.digits > 12)) {
291
298
  sol.warnings.push(
292
299
  'the solve did not converge, and nothing specific showed why. ' +
293
300
  'Check the definition with describe() (from driftlet/kit); approach this state in smaller steps (a sweep, or advance() in time); or refine the grid.',
package/src/solver.js CHANGED
@@ -217,11 +217,12 @@ function gmres(op, b, x, { m = 12, restarts = 8, tol = 1e-8 } = {}) {
217
217
  import { BlockTridiagonal, ComplexBlockTridiagonal } from './blockTridiagonal.js';
218
218
  import { bernoulli, bernoulliDerivative } from './bernoulli.js';
219
219
  import { EPS0, FARADAY } from './constants.js';
220
- import { nextBreakpoint, sourceAt } from './device.js';
220
+ import { sourceAt } from './device.js';
221
221
  import { DifferenceTerms } from './difference.js';
222
222
  import { DeviceError } from './errors.js';
223
223
  import { chargeOnly, conservedMoieties, feeds, findLaws, noCurrent } from './laws.js';
224
224
  import { applyLaws, captureLaws, captureLocal, snapFlat, solveBordered } from './lawRows.js';
225
+ import { advance as advanceFn, integrate as integrateFn, step as stepFn, violentStart } from './timeSteps.js';
225
226
  import { powi, powr } from './pow.js';
226
227
 
227
228
  // A profile's value at x (c0, kf): piecewise linear, constant beyond its ends.
@@ -263,11 +264,6 @@ export class SolverError extends Error {
263
264
  }
264
265
  }
265
266
 
266
- // Lagrange weights at x through the points s0, s1, s2.
267
- function lagrange3(x, s0, s1, s2) {
268
- return [((x - s1) * (x - s2)) / ((s0 - s1) * (s0 - s2)), ((x - s0) * (x - s2)) / ((s1 - s0) * (s1 - s2)), ((x - s0) * (x - s1)) / ((s2 - s0) * (s2 - s1))];
269
- }
270
-
271
267
  // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
272
268
  function gateRate(law, V) {
273
269
  const { rate, midpoint, scale } = law;
@@ -3168,18 +3164,9 @@ export class Solver {
3168
3164
  * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
3169
3165
  * The end time is always t + dt; the result reports the substeps taken.
3170
3166
  */
3167
+ // A time step, halved until it converges (src/timeSteps.js).
3171
3168
  advance(dt, opts, depth = 0) {
3172
- const r = this.step(dt, opts);
3173
- if (r.converged || depth >= 30) return { ...r, substeps: 1 };
3174
- const a = this.advance(dt / 2, opts, depth + 1);
3175
- if (!a.converged) return a;
3176
- const b = this.advance(dt / 2, opts, depth + 1);
3177
- return {
3178
- converged: b.converged,
3179
- iterations: r.iterations + a.iterations + b.iterations,
3180
- history: b.history,
3181
- substeps: a.substeps + b.substeps,
3182
- };
3169
+ return advanceFn(this, dt, opts, depth);
3183
3170
  }
3184
3171
 
3185
3172
  // u += scale·d in compensated arithmetic (Knuth two-sum, then renormalise hi/lo).
@@ -3196,315 +3183,14 @@ export class Solver {
3196
3183
  }
3197
3184
  }
3198
3185
 
3199
- /**
3200
- * One time step of size dt. Backward Euler by default; with `method: 'bdf2'`, variable-step
3201
- * BDF2 once a previous step exists (and the step ratio is at most 2, for stability). On
3202
- * failure the state is restored.
3203
- *
3204
- * BDF2's storage term (a0 c − (1+ω) c_n + ω²/(1+ω) c_{n−1})/dt is written in backward-Euler
3205
- * form (c − c*)/(dt/a0), so assembly is shared: c* and the displacement histories replace
3206
- * the start-of-step values, and dt/a0 replaces dt.
3207
- */
3186
+ // One time step (src/timeSteps.js).
3208
3187
  step(dt, opts = {}) {
3209
- this.atSteady = false;
3210
- const prev = this.history[0];
3211
- const w = prev ? dt / prev.dt : 0;
3212
- const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
3213
- this.uPrev.set(this.u);
3214
- this.uPrevLo.set(this.uLo);
3215
- this.termVPrev.set(this.termV);
3216
- this.computeConcentrations();
3217
- const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
3218
- const gN = Float64Array.from(this.gateList, (gt) => this.u[gt.o] + this.uLo[gt.o]);
3219
- // Contact displacement before the step: as it was at the end of the previous step (under
3220
- // the parameters then), so a gate-voltage change shows up as displacement current.
3221
- if (!this.contactDEnd || !this.portQEnd) {
3222
- this._assembleBookkeeping(dt);
3223
- this.contactDEnd = { ...this.contactD };
3224
- this.portQEnd = Float64Array.from(this.portQ);
3225
- }
3226
- const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
3227
- if (opts.guess) this._guess(opts.guess); // start Newton from a predicted state
3228
- let dtEff = dt;
3229
- if (bdf) {
3230
- const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
3231
- const { cOld } = this;
3232
- for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
3233
- for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
3234
- for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
3235
- for (let k = 0; k < gN.length; k++) this.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
3236
- this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
3237
- this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
3238
- dtEff = dt / a0;
3239
- } else {
3240
- this.cOld.set(cN);
3241
- this.thOld.set(thN);
3242
- this.th0Old.set(th0N);
3243
- this.gateOld.set(gN);
3244
- this.contactDStart = DN;
3245
- this.portQStart = QN;
3246
- }
3247
- // Implicit: sources at the step's end, as seen from within the step (before any jump there).
3248
- // A step landing on a breakpoint ends on it exactly.
3249
- const tEnd = this.landing !== undefined && Math.abs(this.time + dt - this.landing) <= 1e-9 * dt ? this.landing : this.time + dt;
3250
- this.sourceTime = this.steady ? this.steadyAt : tEnd;
3251
- this.sourceBefore = !this.steady;
3252
- this.combining = true;
3253
- this.lawForm = new Map(); // (see circuitLaws in src/lawRows.js)
3254
- let result;
3255
- try {
3256
- result = this.newton(dtEff, opts);
3257
- if (!result.converged && dtEff === Infinity && this.steady && this.islands.length > 0) {
3258
- // Plain solves that don't converge may be lost along an island's level (two regions
3259
- // conducting 1e20 times less hold it): once more from the start, with the islands'
3260
- // summed rows in throughout (see newton).
3261
- this.u.set(this.uPrev);
3262
- this.uLo.set(this.uPrevLo);
3263
- this.termV.set(this.termVPrev);
3264
- if (opts.guess) this._guess(opts.guess);
3265
- const again = this.newton(dtEff, { ...opts, islands: true });
3266
- result = { ...again, iterations: result.iterations + again.iterations };
3267
- }
3268
- if (!result.converged && Number.isFinite(dtEff) && [...this.lawForm.values()].some((v) => !v)) {
3269
- // A terminal read by its flux whose response is lost to round-off (a floating gate's
3270
- // charging, below its metal's G Δη): once more, read by the change (see circuitLaws in
3271
- // src/lawRows.js).
3272
- this.u.set(this.uPrev);
3273
- this.uLo.set(this.uPrevLo);
3274
- this.termV.set(this.termVPrev);
3275
- if (opts.guess) this._guess(opts.guess);
3276
- for (const cs of this.lawForm.keys()) this.lawForm.set(cs, true);
3277
- const again = this.newton(dtEff, opts);
3278
- result = { ...again, iterations: result.iterations + again.iterations };
3279
- }
3280
- } finally {
3281
- this.combining = false;
3282
- }
3283
- result.bdf = bdf;
3284
- if (result.converged) {
3285
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
3286
- if (this.history.length > 3) this.history.length = 3;
3287
- this.time = tEnd;
3288
- this.lastDt = dtEff;
3289
- this.contactDOld = this.contactDStart;
3290
- this.portQOld = this.portQStart;
3291
- this._accumulateBoundaryIntake(dtEff, cN);
3292
- // (In time, the state is no longer the steady solve's that a continuation would start from.)
3293
- if (!this.steady) {
3294
- this.solvedV = undefined;
3295
- this.stepped = true;
3296
- }
3297
- } else {
3298
- this.u.set(this.uPrev);
3299
- this.uLo.set(this.uPrevLo);
3300
- this.termV.set(this.termVPrev);
3301
- this.sourceTime = this.steady ? this.steadyAt : this.time;
3302
- this.computeConcentrations();
3303
- }
3304
- this.sourceBefore = false;
3305
- return result;
3188
+ return stepFn(this, dt, opts);
3306
3189
  }
3307
3190
 
3308
- // Newton's unknowns set to a guess (e.g. extrapolated from the history). Only its unknowns: the
3309
- // other slots never change (see _activeSlots), and among them is a gauge, an insertion host's
3310
- // φ̂, that nothing pulls back. Extrapolated through steps that grow, it wandered off
3311
- // geometrically, 147 to 8000 thermal units in a battery's first 100 s and 1e11 by 1e5 s, where
3312
- // its species' levels (η − zφ̂, summed back by the host's statistics) lost their digits to it.
3313
- _guess(guess) {
3314
- const { u, uLo } = this;
3315
- for (const k of this.fullOf) {
3316
- u[k] = guess[k];
3317
- uLo[k] = 0;
3318
- }
3319
- }
3320
-
3321
- /**
3322
- * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
3323
- * estimated against an explicit predictor through the previous states, and controlled to
3324
- * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
3325
- * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
3326
- * animation can call it once per frame; the step size carries over between calls.
3327
- */
3191
+ // Adaptive time stepping to tEnd (src/timeSteps.js).
3328
3192
  integrate(tEnd, opts = {}) {
3329
- // tEnd is an absolute time. Not a number at all is an error; a NaN (a first animation frame's
3330
- // clock, or a time read from a field that doesn't exist) does nothing, and says so.
3331
- const absolute = `tEnd is the time to reach in s, absolute (the device is at t = ${this.time}, which a solution gives as its time)`;
3332
- if (typeof tEnd !== 'number') throw new SolverError(`advance(tEnd): ${absolute}; got ${tEnd}`);
3333
- if (Number.isNaN(tEnd)) return { converged: true, done: false, steps: 0, rejected: 0, iterations: 0, trace: { t: [], current: [], voltage: [] }, stopped: `advance(NaN) did nothing: ${absolute}` };
3334
- const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
3335
- const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
3336
- const start = clock();
3337
- const sample = this._probes(opts.probes);
3338
- const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
3339
- let steps = 0, rejected = 0, iterations = 0, failed = false;
3340
- // Already there (an animation frame with no time to add): nothing to do, and the step size
3341
- // carried to the next call stays as it was. A time already passed, or reached again outside
3342
- // an animation, is most likely advance(dt) read as a duration: say so.
3343
- if (!(tEnd > this.time)) {
3344
- const relative = tEnd < this.time || (this.time > 0 && opts.budgetMs === undefined);
3345
- return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace, ...(relative ? { stopped: `advance(${tEnd}) did nothing: ${absolute}. To run for a further dt, advance(sol.time + dt)` } : {}) };
3346
- }
3347
- const t0 = this.time;
3348
- let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
3349
- if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
3350
- // The shortest step worth trying: round-off relative to the time, or the device's fastest
3351
- // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
3352
- const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
3353
- const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
3354
- const firstErrs = []; // the first step's error at each size tried (see below)
3355
- const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
3356
- while (this.time < tEnd) {
3357
- if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
3358
- // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
3359
- // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
3360
- // strictly neutral device, fails outright. Round-off of the time and negligible beside the
3361
- // interval asked for: by the time alone, a GITT pulse of 0.01 s at t = 1e9 s was skipped
3362
- // whole (the time moved, nothing else did), and from 1e10 s 17% of a pulse train's charge.
3363
- if (tEnd - this.time <= 1e-10 * Math.abs(tEnd) && tEnd - this.time <= 1e-9 * (tEnd - t0)) {
3364
- this.time = tEnd;
3365
- break;
3366
- }
3367
- const remaining = tEnd - this.time;
3368
- let h = Math.min(dt, dtMax);
3369
- let clamped = h >= remaining * (1 - 1e-9);
3370
- if (clamped) h = remaining;
3371
- // Land on the waveforms' breakpoints, where a source's slope jumps.
3372
- const tb = this.terms.reduce((m, t) => Math.min(m, nextBreakpoint(t.drive.src, this.time)), Infinity);
3373
- const atBreak = tb - this.time <= h * (1 + 1e-9);
3374
- if (atBreak) {
3375
- h = tb - this.time;
3376
- clamped = true;
3377
- }
3378
- this.landing = atBreak ? tb : clamped ? tEnd : undefined;
3379
- const snap = this._snapshot();
3380
- let err, p, half = null;
3381
- if (this.history.length > 0) firstErrs.length = 0;
3382
- if (this.history.length === 0) {
3383
- // First step: backward Euler, checked against two half steps (whose result is kept).
3384
- const full = this.step(h);
3385
- iterations += full.iterations;
3386
- if (full.converged) {
3387
- const uFull = Float64Array.from(this.u);
3388
- this._restore(snap);
3389
- const a = this.step(h / 2);
3390
- // The first half step is kept too, so it goes in the trace (a jump's charge is in it).
3391
- half = a.converged ? { t: this.time, current: this._terminalCurrent(), voltage: this.termV[1] - this.termV[0], probes: sample?.read() } : null;
3392
- const b = a.converged ? this.step(h / 2) : a;
3393
- iterations += a.iterations + (b === a ? 0 : b.iterations);
3394
- if (b.converged) {
3395
- err = this._errorNorm(uFull);
3396
- p = 1;
3397
- }
3398
- }
3399
- if (err === undefined) {
3400
- this._restore(snap);
3401
- rejected++;
3402
- dt = h / 4;
3403
- if (dt < floor) { failed = true; break; }
3404
- continue;
3405
- }
3406
- } else {
3407
- // (Order 2 once three consistent states can check it.)
3408
- const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, h) });
3409
- iterations += r.iterations;
3410
- if (!r.converged) {
3411
- rejected++;
3412
- dt = h / 4;
3413
- if (dt < floor) { failed = true; break; }
3414
- continue;
3415
- }
3416
- p = this._predict(pred, r.bdf);
3417
- err = this._errorNorm(pred) * p.scale;
3418
- p = p.order;
3419
- }
3420
- // A first step whose error doesn't shrink with it: a jump in a level held at a boundary
3421
- // (a bath's, after a voltage step) starts a profile self-similar in x/√t, so a first step
3422
- // of any length errs alike, down to the grid's own diffusion time, below Newton's reach.
3423
- // Three tries in a row, each within a factor 2 of the last, say so: the error is the
3424
- // jump's, not the step's. Take the step (backward Euler damps it as it should) and grow.
3425
- if (half && err > tol) firstErrs.push(err);
3426
- const jump = half && firstErrs.length >= 3 && firstErrs.slice(-3).every((e, j, a) => j === 0 || (e > 0.5 * a[j - 1] && e < 2 * a[j - 1]));
3427
- if (err > tol && !jump) {
3428
- this._restore(snap);
3429
- rejected++;
3430
- dt = h * factor(err, p);
3431
- continue;
3432
- }
3433
- steps++;
3434
- // The state the first step started from needn't satisfy the algebraic equations (φ in a
3435
- // strictly neutral region, an interface's unknowns, after a start or a jump), and they
3436
- // jump in the first instant; a predictor through it would see that jump as error on every
3437
- // step after. The history starts from the half step.
3438
- if (half) this.history.length = 1;
3439
- dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
3440
- if (atBreak) this.history = []; // the solution's slope jumps here: restart the order
3441
- if (half) {
3442
- trace.t.push(half.t);
3443
- trace.current.push(half.current);
3444
- trace.voltage.push(half.voltage);
3445
- if (sample) sample.push(half.probes);
3446
- }
3447
- trace.t.push(this.time);
3448
- trace.current.push(this._terminalCurrent());
3449
- trace.voltage.push(this.termV[1] - this.termV[0]);
3450
- if (sample) sample.push(sample.read());
3451
- }
3452
- this.dtNext = dt;
3453
- this.landing = undefined;
3454
- // Stopped short, other than on the caller's wall-time budget: say where, and why.
3455
- let stopped;
3456
- const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3457
- // (What usually does it, most likely first. Round 11's agents read the old list, which led
3458
- // with a drive's jump, as blaming a modest current pulse: a host emptied at its surface, and
3459
- // an electrode at open circuit with no double layer, held only by a slow reaction.)
3460
- if (failed) stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. Look for: a host or surface driven to its limit (emptied or filled at its surface by a current it can no longer pass); an electrode at open circuit held only by a slow reaction, with no double-layer capacitance to keep its level (give its face one: phi capacitive); a drive's jump too big for one step (ramp it); a driven current the device cannot keep passing (a diode's reverse recovery past its storage time: stop there); or a device the steady solves also find hard.`;
3461
- else if (this.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
3462
- return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3463
- }
3464
-
3465
- // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
3466
- // the two most recent history entries: a starting guess for Newton. (In offsets built from the
3467
- // steps' own lengths, never differences of absolute times: a second into a run, after a jump
3468
- // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
3469
- _extrapolate(out, h) {
3470
- const [e0, e1] = this.history;
3471
- const u = this.u;
3472
- if (!e0) return undefined;
3473
- if (!e1) {
3474
- const l = h / e0.dt;
3475
- for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
3476
- return out;
3477
- }
3478
- const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
3479
- for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
3480
- return out;
3481
- }
3482
-
3483
- // Explicit predictor at the new time through the previous states (quadratic after BDF2,
3484
- // linear after backward Euler), and the factor turning |u − pred| into the local error.
3485
- _predict(pred, bdf) {
3486
- const [e0, e1, e2] = this.history;
3487
- const h = e0.dt;
3488
- if (!e1) {
3489
- pred.set(e0.u);
3490
- return { scale: 1, order: 1 };
3491
- }
3492
- const hp = e1.dt;
3493
- if (bdf && e2) {
3494
- const hpp = e2.dt;
3495
- // Lagrange through the three states before at the new time (offsets as in _extrapolate).
3496
- const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
3497
- for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
3498
- const w = h / hp;
3499
- const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
3500
- const Cp = h * (h + hp) * (h + hp + hpp);
3501
- return { scale: Cc / (Cc + Cp), order: 2 };
3502
- }
3503
- const l0 = (h + hp) / hp, l1 = 1 - l0;
3504
- for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
3505
- // Backward Euler's error against a linear predictor; after a BDF2 step without enough
3506
- // history this overestimates, which is safe.
3507
- return { scale: bdf ? 1 : h / (2 * h + hp), order: 1 };
3193
+ return integrateFn(this, tEnd, opts);
3508
3194
  }
3509
3195
 
3510
3196
  // Probes for a transient's trace: { x, species, quantity: 'c' | 'V', region } read after every
@@ -3578,23 +3264,6 @@ export class Solver {
3578
3264
  };
3579
3265
  }
3580
3266
 
3581
- // Largest difference from ref among the potentials that carry state, in thermal units.
3582
- _errorNorm(ref) {
3583
- const { n, M, u } = this;
3584
- let mx = 0;
3585
- for (let g = 0; g < this.nNodes; g++) {
3586
- const b = this.blockOfNode[g];
3587
- if (!this.phiUndefined[g]) mx = Math.max(mx, Math.abs(u[b * M] - ref[b * M]));
3588
- for (let i = 0; i < n; i++) {
3589
- if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
3590
- }
3591
- const k = this.surfPort[g]; // an electrode surface's coverages, as η
3592
- if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(u[b * M + 1 + n + q] - ref[b * M + 1 + n + q]));
3593
- }
3594
- for (const { o } of this.gateList) mx = Math.max(mx, Math.abs(u[o] - ref[o])); // (a fraction: 1e-3 of m³ is 0.3% of P)
3595
- return mx;
3596
- }
3597
-
3598
3267
  // Start from another solver's state for the same device on another grid (a refined one): each
3599
3268
  // node's unknowns interpolated linearly in x within its region, each face's copied, and the
3600
3269
  // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
@@ -4350,6 +4019,7 @@ export class Solver {
4350
4019
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
4351
4020
  // a change of drive).
4352
4021
  solveSteady(opts = {}) {
4022
+ this.violent = this.history.length === 0 && !this.stepped ? violentStart(this) : []; // (for a failure to say: see src/timeSteps.js)
4353
4023
  this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4354
4024
  this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
4355
4025
  captureLaws(this);
@@ -0,0 +1,502 @@
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
+ if (failed && violent.length > 0)
394
+ 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.`;
395
+ 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.`;
396
+ 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.`;
397
+ return { converged: !failed, done: s.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
398
+ }
399
+
400
+ // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
401
+ // the two most recent history entries: a starting guess for Newton. (In offsets built from the
402
+ // steps' own lengths, never differences of absolute times: a second into a run, after a jump
403
+ // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
404
+ function extrapolate(s, out, h) {
405
+ const [e0, e1] = s.history;
406
+ const u = s.u;
407
+ if (!e0) return undefined;
408
+ if (!e1) {
409
+ const l = h / e0.dt;
410
+ for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
411
+ return out;
412
+ }
413
+ const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
414
+ for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
415
+ return out;
416
+ }
417
+
418
+ // Explicit predictor at the new time through the previous states (quadratic after BDF2,
419
+ // linear after backward Euler), and the factor turning |u − pred| into the local error.
420
+ function predict(s, pred, bdf) {
421
+ const [e0, e1, e2] = s.history;
422
+ const h = e0.dt;
423
+ if (!e1) {
424
+ pred.set(e0.u);
425
+ return { scale: 1, order: 1 };
426
+ }
427
+ const hp = e1.dt;
428
+ if (bdf && e2) {
429
+ const hpp = e2.dt;
430
+ // Lagrange through the three states before at the new time (offsets as in extrapolate).
431
+ const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
432
+ for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
433
+ const w = h / hp;
434
+ const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
435
+ const Cp = h * (h + hp) * (h + hp + hpp);
436
+ return { scale: Cc / (Cc + Cp), order: 2 };
437
+ }
438
+ const l0 = (h + hp) / hp, l1 = 1 - l0;
439
+ for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
440
+ // Backward Euler's error against a linear predictor; after a BDF2 step without enough
441
+ // history this overestimates, which is safe.
442
+ return { scale: bdf ? 1 : h / (2 * h + hp), order: 1 };
443
+ }
444
+
445
+ // Largest difference from ref among the potentials that carry state, in thermal units.
446
+ function errorNorm(s, ref) {
447
+ const { n, M, u } = s;
448
+ let mx = 0;
449
+ for (let g = 0; g < s.nNodes; g++) {
450
+ const b = s.blockOfNode[g];
451
+ if (!s.phiUndefined[g]) mx = Math.max(mx, Math.abs(u[b * M] - ref[b * M]));
452
+ for (let i = 0; i < n; i++) {
453
+ if (s.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
454
+ }
455
+ const k = s.surfPort[g]; // an electrode surface's coverages, as η
456
+ 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]));
457
+ }
458
+ 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)
459
+ return mx;
460
+ }
461
+
462
+ // The floating double layers a cold start leaves far (0.1 V or more) from the equilibrium of a
463
+ // path beside them: a contact with a capacitive φ, or a port with a capacitance, at I = 0 or
464
+ // behind a resistance, with a link holding a charged species at an outside level V + offset,
465
+ // or (a port) a reaction that takes its carrier. Each as words for a message.
466
+ export function violentStart(s) {
467
+ const out = [], { M, n, z, VT, model } = s;
468
+ const check = (what, k, links, g) => {
469
+ for (let i = 0; i < n; i++) {
470
+ const l = links[i];
471
+ if (z[i] === 0 || !['conductance', 'equilibrium'].includes(l.type) || !s.present[g * n + i]) continue;
472
+ const gap = (VT * s.u[s.blockOfNode[g] * M + 1 + i]) / z[i] - (s.termV[k] + (l.offset ?? 0));
473
+ if (Math.abs(gap) >= 0.1) out.push(`${what}'s ${model.species[i].name} link is ${Math.abs(gap).toFixed(2)} V from equilibrium`);
474
+ }
475
+ };
476
+ for (const k of s.floating) {
477
+ const t = s.terms[k];
478
+ if (t.kind === 'contact') {
479
+ const ct = model.contacts[t.side];
480
+ if (ct.phi.type === 'capacitive') check(`contact ${t.side}`, k, ct.species, t.side === 'left' ? 0 : s.nNodes - 1);
481
+ continue;
482
+ }
483
+ const port = model.ports[t.index];
484
+ if (!port.capacitance) continue;
485
+ check(`port ${port.name}`, k, port.species, port.nodes[0]);
486
+ // Its reactions at its first node: the affinity a = fixedA − Σ ν η, in volts by how it moves
487
+ // with the port's voltage (−Σ ν z/V_T over the carrier's part).
488
+ const b = s.blockOfNode[port.nodes[0]];
489
+ port.reactions.forEach((rx, j) => {
490
+ let a = rx.fixedA, slope = 0;
491
+ for (const p of rx.part) {
492
+ if (p.side === 1) {
493
+ a -= p.nu * s.portEta(port, p.i);
494
+ slope -= (p.nu * z[p.i]) / VT;
495
+ } else a -= p.nu * s.u[b * M + (p.side === 2 ? 1 + n + p.s : 1 + p.i)];
496
+ }
497
+ const gap = slope !== 0 ? Math.abs(a / slope) : 0;
498
+ if (gap >= 0.1) out.push(`port ${port.name}'s reactions[${j}] is ${gap.toFixed(2)} V from equilibrium`);
499
+ });
500
+ }
501
+ return out;
502
+ }
package/types/solver.d.ts CHANGED
@@ -354,32 +354,7 @@ export class Solver {
354
354
  */
355
355
  advance(dt: any, opts: any, depth?: number): any;
356
356
  _addToState(d: any, scale: any): void;
357
- /**
358
- * One time step of size dt. Backward Euler by default; with `method: 'bdf2'`, variable-step
359
- * BDF2 once a previous step exists (and the step ratio is at most 2, for stability). On
360
- * failure the state is restored.
361
- *
362
- * BDF2's storage term (a0 c − (1+ω) c_n + ω²/(1+ω) c_{n−1})/dt is written in backward-Euler
363
- * form (c − c*)/(dt/a0), so assembly is shared: c* and the displacement histories replace
364
- * the start-of-step values, and dt/a0 replaces dt.
365
- */
366
- step(dt: any, opts?: {}): {
367
- converged: boolean;
368
- iterations: number;
369
- history: number[];
370
- };
371
- contactDEnd: any;
372
- sourceBefore: boolean;
373
- solvedV: any;
374
- stepped: boolean;
375
- _guess(guess: any): void;
376
- /**
377
- * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
378
- * estimated against an explicit predictor through the previous states, and controlled to
379
- * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
380
- * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
381
- * animation can call it once per frame; the step size carries over between calls.
382
- */
357
+ step(dt: any, opts?: {}): any;
383
358
  integrate(tEnd: any, opts?: {}): {
384
359
  stopped?: string;
385
360
  converged: boolean;
@@ -394,19 +369,13 @@ export class Solver {
394
369
  voltage: any[];
395
370
  };
396
371
  };
397
- landing: any;
398
- _extrapolate(out: any, h: any): any;
399
- _predict(pred: any, bdf: any): {
400
- scale: number;
401
- order: number;
402
- };
403
372
  _probes(probes: any): {
404
373
  out: any;
405
374
  read: () => any;
406
375
  push: (values: any) => any;
407
376
  };
408
- _errorNorm(ref: any): number;
409
377
  _warmFrom(src: any): void;
378
+ solvedV: any;
410
379
  _snapshot(): {
411
380
  u: Float64Array<ArrayBuffer>;
412
381
  uLo: Float64Array<ArrayBuffer>;
@@ -435,6 +404,7 @@ export class Solver {
435
404
  sourceTime: number;
436
405
  };
437
406
  _restore(s: any): void;
407
+ contactDEnd: any;
438
408
  passedCurrent(k: any): any;
439
409
  _terminalCurrent(): number;
440
410
  /**
@@ -498,6 +468,7 @@ export class Solver {
498
468
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
499
469
  */
500
470
  solveSteady(opts?: {}): any;
471
+ violent: any[];
501
472
  unreached: {
502
473
  terminal: any;
503
474
  target: any;
@@ -512,6 +483,7 @@ export class Solver {
512
483
  steadyAt: number;
513
484
  _solveSteadyAll(opts: any): any;
514
485
  _steadyFromHere(opts: any): any;
486
+ stepped: boolean;
515
487
  dimmer: boolean;
516
488
  _bound(k: any): boolean;
517
489
  _floatingContinuation(opts: any, r: any): any;
@@ -531,13 +503,13 @@ export class Solver {
531
503
  iterations: number;
532
504
  history: ({
533
505
  dt: number;
534
- converged: boolean;
535
- iterations: number;
506
+ converged: any;
507
+ iterations: any;
536
508
  maxStep?: undefined;
537
509
  } | {
538
510
  dt: number;
539
- converged: boolean;
540
- iterations: number;
511
+ converged: any;
512
+ iterations: any;
541
513
  maxStep: number;
542
514
  })[];
543
515
  };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
3
+ * Converged, the end time is t + dt; failed, wherever the halves that converged got to (the
4
+ * substeps counted are those of the last attempt's halves).
5
+ */
6
+ export function advance(s: any, dt: any, opts: any, depth?: number): any;
7
+ /**
8
+ * One time step of size dt. Backward Euler by default; with `method: 'bdf2'`, variable-step
9
+ * BDF2 once a previous step exists (and the step ratio is at most 2, for stability). On
10
+ * failure the state is restored.
11
+ *
12
+ * BDF2's storage term (a0 c − (1+ω) c_n + ω²/(1+ω) c_{n−1})/dt is written in backward-Euler
13
+ * form (c − c*)/(dt/a0), so assembly is shared: c* and the displacement histories replace
14
+ * the start-of-step values, and dt/a0 replaces dt.
15
+ */
16
+ export function step(s: any, dt: any, opts?: {}): any;
17
+ /**
18
+ * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
19
+ * estimated against an explicit predictor through the previous states, and controlled to
20
+ * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
21
+ * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
22
+ * animation can call it once per frame; the step size carries over between calls.
23
+ */
24
+ export function integrate(s: any, tEnd: any, opts?: {}): {
25
+ stopped?: string;
26
+ converged: boolean;
27
+ done: boolean;
28
+ steps: number;
29
+ rejected: number;
30
+ iterations: number;
31
+ trace: {
32
+ probes?: any;
33
+ t: any[];
34
+ current: any[];
35
+ voltage: any[];
36
+ };
37
+ };
38
+ export function violentStart(s: any): any[];