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 +1 -1
- package/docs/device.md +7 -1
- package/docs/internals.md +4 -2
- package/llms.txt +6 -6
- package/package.json +1 -1
- package/src/solution.js +8 -1
- package/src/solver.js +9 -339
- package/src/timeSteps.js +502 -0
- package/types/solver.d.ts +9 -37
- package/types/timeSteps.d.ts +38 -0
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.
|
|
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).
|
|
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
|
|
73
|
-
|
|
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.
|
|
34
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
35
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
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.
|
|
223
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
224
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.
|
|
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
|
+
"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 &&
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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);
|
package/src/timeSteps.js
ADDED
|
@@ -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:
|
|
535
|
-
iterations:
|
|
506
|
+
converged: any;
|
|
507
|
+
iterations: any;
|
|
536
508
|
maxStep?: undefined;
|
|
537
509
|
} | {
|
|
538
510
|
dt: number;
|
|
539
|
-
converged:
|
|
540
|
-
iterations:
|
|
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[];
|