driftlet 1.2.2 → 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.2/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
@@ -110,6 +110,11 @@ regions: [
110
110
  reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
111
111
  logarithmic in them); to have none of a species in a region, leave it out of that region's
112
112
  material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
113
+ Where two strictly neutral regions meet at a face that holds ions in equilibrium and their
114
+ `c0` leaves it out of equilibrium (a different fixed charge or immobile ion on each side), the
115
+ two nodes at the face start in it, as the face reaches it at $`t = 0^+`$: the ions it passes
116
+ shared between them so their $`\bar\mu`$ is equal across it and each side neutral, each
117
+ one's amount over the two nodes kept, everything else as `c0` has it.
113
118
  An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
114
119
  the region's own), piecewise linear between its points and constant beyond its ends, so a
115
120
  transient can start from a state it didn't simulate reaching: a packet already injected, a
@@ -657,7 +662,13 @@ voltage, the port is a potentiostat. At `I: 0` (or with nothing else to carry cu
657
662
  reactions settle at the mixed potential, the corrosion potential where anodic and cathodic
658
663
  currents cancel over the window, while each node can be a net anode or a net cathode; a floating
659
664
  one starts there, for the start's composition (with a double layer, it starts uncharged and
660
- 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
661
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
662
673
  `rates[q][j] * area[j]`, and per area of a film's floor (thickness h, `area` 1/h) the rate itself.
663
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,
@@ -83,4 +85,11 @@ The rest of `src/solver.js` is to be split along these lines, each piece with it
83
85
  A review of one module reads its contract, its code and its tests, and nothing else unless the
84
86
  contract points there. Each claim cites `file:line` with the line quoted, so it can be checked
85
87
  mechanically, and a finding comes with the device or script that shows it. A claim about code
86
- not in view is a question, not a finding.
88
+ not in view is a question, not a finding. Two instruments check a change to the core beyond the tests'
89
+ answers: `test/jacobian.test.js` (every row's derivatives against finite differences of its
90
+ residual) and, for a move or refactor meant to change nothing, `tools/fingerprint.js`: a hash of
91
+ the solver's state after every solve in the suite (on a fixed clock, so runs stopped by a time
92
+ budget repeat), recorded before and after and compared. `tools/check-hook.js`, loaded into any run (`node --import
93
+ ./tools/check-hook.js …`: the tests, the law sweep, a fuzzer), checks the laws' cheap invariants
94
+ as the solver goes: no row taken twice, none inactive, no stray ∂/∂V in a law's row, findLaws'
95
+ structure unwritten.
@@ -171,9 +171,47 @@ and in a time step's reading of its current), none of them in the sweep's device
171
171
  `test/laws.test.js`. On the code before those fixes the second family has 14 devices that
172
172
  disagree (a port's voltage at 0 for −1.42 V, θ 0.05 for 0.015, a contact 24 mV off); after, none,
173
173
  26 agree and 34 transients fail (see the roadmap). The first family's laws come out unchanged by
174
- the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices, gives the
175
- same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a membrane
176
- capacitor where the transient loses K⁺.
174
+ the fixes, device by device, so its results above stand. On 1.2.2 the whole sweep, 870 devices,
175
+ gives the same: 605 agree, 117 transients fail, and 13 differ, each a capacitive contact beside a
176
+ membrane capacitor where the transient loses K⁺. Since a face out of equilibrium in `c0` starts
177
+ in it (1.2.3), 607 agree and 115 fail, the same 13 differing.
178
+
179
+ ## Consistent: the derivatives
180
+
181
+ A Jacobian entry that's wrong still converges to the right answer, its residual being right, so
182
+ no test of answers sees it: it shows only as Newton taking longer, or failing on a harder device.
183
+ `test/jacobian.test.js` compares the whole Newton system, column by column, with central
184
+ differences of its own residual, on ten devices that between them write every law's row (flat
185
+ stretches, affinities, amounts kept in balance rows and in circuit rows with an electrode's or a
186
+ contact's charge, node-by-node laws with a surface, metals' charges), crowded statistics, face
187
+ and port reactions and floating terminals. It checks inside a real `solve()` and a real
188
+ `advance()`, at their second assembly, each in the unknowns Newton solves for there (a time step's
189
+ charge rows included). It came from a cold review of `src/lawRows.js`, which found a node-by-node
190
+ law's row keeping a port reaction's ∂/∂V (an extra Newton iteration, the answer the same); broken
191
+ once each way (that entry back, a storage term 0.1% off in either time-step path), it fails.
192
+
193
+ ## Measured: how much the tests see
194
+
195
+ A mutation run makes one small slip at a time in a module (an operator flipped, a comparison
196
+ loosened, a statement dropped, a 0 for a 1), runs the tests on it, and counts the slips no test
197
+ fails. On 18 Oct 2026, 120 of them, sampled from `src/laws.js` and `src/lawRows.js`'s 1420: 69
198
+ failed a test, and 51 got through. Each of the 51 was then looked at:
199
+ - In `src/laws.js` (29): 20 change no law on any of the law sweep's 735 devices, 2 only a
200
+ workspace's size, and 6 change laws on 12 to 635 of them while every answer stays right (the
201
+ islands', which Newton seldom needs; a λ read only by long steps). One changed the answers: it
202
+ held a capacitive contact's charge in the wrong law, and 85 of the sweep's devices came out
203
+ wrong, with all 470 tests passing. Its device is now in `test/laws.test.js`.
204
+ - In `src/lawRows.js` (22): the Jacobian test had never seen islands' rows, a long step that
205
+ reads a law by its change, or a law with a surface or a capacitive electrode in a time step.
206
+ With them in (a charged pore, three of the sweep's devices), 6 more fail. Of the 16 left, 6
207
+ change nothing (a bound the loop never reaches), 1 only a heuristic's estimate, 2 the pivoting
208
+ of the small dense solve (robustness only), and 7 are narrow paths no device here takes: two
209
+ floating electrodes whose law counts the other's charge, an equilibrium face inside a flat
210
+ stretch, a contact's law read by its change.
211
+
212
+ A slip that gives the right answer by another route isn't a gap; what the run found that was,
213
+ was one wrong answer the sweep sees and the suite didn't, and a test that had never looked at
214
+ two of the paths it claimed.
177
215
 
178
216
  ## Hard cases
179
217
 
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.2/src/index.js';
34
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/kit.js';
35
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/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.2/src/index.js';
223
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/src/kit.js';
224
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.2.2/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.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": {
@@ -34,6 +34,7 @@
34
34
  "smoke": "node tools/smoke.js",
35
35
  "shots": "node tools/shots.js",
36
36
  "lawsweep": "node tools/lawsweep.js",
37
+ "fingerprint": "node tools/fingerprint.js",
37
38
  "prepublishOnly": "npm test && npm run types",
38
39
  "types": "rm -rf types && npx -y -p typescript@5 tsc --allowJs --declaration --emitDeclarationOnly --target es2022 --module nodenext --moduleResolution nodenext --outDir types src/index.js src/kit.js src/plot.js"
39
40
  },
package/src/lawRows.js CHANGED
@@ -349,6 +349,8 @@ function lawAmount(s, cs) {
349
349
  cs.w[len++] = -f * cap * s.VT;
350
350
  });
351
351
  }
352
+ // (A typed array drops writes past its end without a word: the law's derivative would lose them.)
353
+ if (len > cs.idx.length) throw new Error(`internal: a law's derivative needs ${len} entries, its workspace holds ${cs.idx.length}`);
352
354
  cs.len = len;
353
355
  return { amount, reference, dV, gross }; // (dV by terminal)
354
356
  }
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;
@@ -1152,6 +1148,53 @@ export class Solver {
1152
1148
  }
1153
1149
  }
1154
1150
  }
1151
+ // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
1152
+ // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
1153
+ // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
1154
+ // first step made to take that jump met an exactly singular pivot. Start there: the ions the
1155
+ // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
1156
+ // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
1157
+ // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
1158
+ // the two nodes' charge summing to what it was, zero.
1159
+ for (let f = 0; f < interfaces.length; f++) {
1160
+ const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
1161
+ const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
1162
+ if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
1163
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = this.blockOfNode[gL] * M, bR = this.blockOfNode[gR] * M;
1164
+ const pass = [];
1165
+ for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
1166
+ if (!pass.some((i) => z[i] !== 0)) continue;
1167
+ const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
1168
+ const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
1169
+ const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
1170
+ let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
1171
+ for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
1172
+ // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
1173
+ const split = (D) =>
1174
+ pass.map((i, j) => {
1175
+ const K = (mL.cRef[i] / mR.cRef[i]) * Math.exp((mR.mu0[i] - mL.mu0[i]) / RT + z[i] * D), cR = amount[j] / (vL * K + vR);
1176
+ return [K * cR, cR];
1177
+ });
1178
+ const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
1179
+ const D0 = u[bR] - u[bL];
1180
+ let lo = D0 - 1, hi = D0 + 1;
1181
+ while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
1182
+ while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
1183
+ if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
1184
+ for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
1185
+ const m = 0.5 * (lo + hi);
1186
+ if (qL(m) < 0) lo = m;
1187
+ else hi = m;
1188
+ }
1189
+ const D = 0.5 * (lo + hi), cs = split(D);
1190
+ const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
1191
+ u[bR] = u[bL] + D;
1192
+ for (let i = 0; i < n; i++) if (mR.present[i] && !pass.includes(i)) u[bR + 1 + i] = Math.log(keep[i] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
1193
+ pass.forEach((i, j) => {
1194
+ u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
1195
+ u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
1196
+ });
1197
+ }
1155
1198
  // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
1156
1199
  // splits between its ion and its electrons. Start its ion level with the same ion's beside it
1157
1200
  // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
@@ -3121,18 +3164,9 @@ export class Solver {
3121
3164
  * Advance by dt, splitting the interval into halves (recursively) wherever Newton fails.
3122
3165
  * The end time is always t + dt; the result reports the substeps taken.
3123
3166
  */
3167
+ // A time step, halved until it converges (src/timeSteps.js).
3124
3168
  advance(dt, opts, depth = 0) {
3125
- const r = this.step(dt, opts);
3126
- if (r.converged || depth >= 30) return { ...r, substeps: 1 };
3127
- const a = this.advance(dt / 2, opts, depth + 1);
3128
- if (!a.converged) return a;
3129
- const b = this.advance(dt / 2, opts, depth + 1);
3130
- return {
3131
- converged: b.converged,
3132
- iterations: r.iterations + a.iterations + b.iterations,
3133
- history: b.history,
3134
- substeps: a.substeps + b.substeps,
3135
- };
3169
+ return advanceFn(this, dt, opts, depth);
3136
3170
  }
3137
3171
 
3138
3172
  // u += scale·d in compensated arithmetic (Knuth two-sum, then renormalise hi/lo).
@@ -3149,315 +3183,14 @@ export class Solver {
3149
3183
  }
3150
3184
  }
3151
3185
 
3152
- /**
3153
- * One time step of size dt. Backward Euler by default; with `method: 'bdf2'`, variable-step
3154
- * BDF2 once a previous step exists (and the step ratio is at most 2, for stability). On
3155
- * failure the state is restored.
3156
- *
3157
- * BDF2's storage term (a0 c − (1+ω) c_n + ω²/(1+ω) c_{n−1})/dt is written in backward-Euler
3158
- * form (c − c*)/(dt/a0), so assembly is shared: c* and the displacement histories replace
3159
- * the start-of-step values, and dt/a0 replaces dt.
3160
- */
3186
+ // One time step (src/timeSteps.js).
3161
3187
  step(dt, opts = {}) {
3162
- this.atSteady = false;
3163
- const prev = this.history[0];
3164
- const w = prev ? dt / prev.dt : 0;
3165
- const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
3166
- this.uPrev.set(this.u);
3167
- this.uPrevLo.set(this.uLo);
3168
- this.termVPrev.set(this.termV);
3169
- this.computeConcentrations();
3170
- const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
3171
- const gN = Float64Array.from(this.gateList, (gt) => this.u[gt.o] + this.uLo[gt.o]);
3172
- // Contact displacement before the step: as it was at the end of the previous step (under
3173
- // the parameters then), so a gate-voltage change shows up as displacement current.
3174
- if (!this.contactDEnd || !this.portQEnd) {
3175
- this._assembleBookkeeping(dt);
3176
- this.contactDEnd = { ...this.contactD };
3177
- this.portQEnd = Float64Array.from(this.portQ);
3178
- }
3179
- const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
3180
- if (opts.guess) this._guess(opts.guess); // start Newton from a predicted state
3181
- let dtEff = dt;
3182
- if (bdf) {
3183
- const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
3184
- const { cOld } = this;
3185
- for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
3186
- for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
3187
- for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
3188
- for (let k = 0; k < gN.length; k++) this.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
3189
- this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
3190
- this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
3191
- dtEff = dt / a0;
3192
- } else {
3193
- this.cOld.set(cN);
3194
- this.thOld.set(thN);
3195
- this.th0Old.set(th0N);
3196
- this.gateOld.set(gN);
3197
- this.contactDStart = DN;
3198
- this.portQStart = QN;
3199
- }
3200
- // Implicit: sources at the step's end, as seen from within the step (before any jump there).
3201
- // A step landing on a breakpoint ends on it exactly.
3202
- const tEnd = this.landing !== undefined && Math.abs(this.time + dt - this.landing) <= 1e-9 * dt ? this.landing : this.time + dt;
3203
- this.sourceTime = this.steady ? this.steadyAt : tEnd;
3204
- this.sourceBefore = !this.steady;
3205
- this.combining = true;
3206
- this.lawForm = new Map(); // (see circuitLaws in src/lawRows.js)
3207
- let result;
3208
- try {
3209
- result = this.newton(dtEff, opts);
3210
- if (!result.converged && dtEff === Infinity && this.steady && this.islands.length > 0) {
3211
- // Plain solves that don't converge may be lost along an island's level (two regions
3212
- // conducting 1e20 times less hold it): once more from the start, with the islands'
3213
- // summed rows in throughout (see newton).
3214
- this.u.set(this.uPrev);
3215
- this.uLo.set(this.uPrevLo);
3216
- this.termV.set(this.termVPrev);
3217
- if (opts.guess) this._guess(opts.guess);
3218
- const again = this.newton(dtEff, { ...opts, islands: true });
3219
- result = { ...again, iterations: result.iterations + again.iterations };
3220
- }
3221
- if (!result.converged && Number.isFinite(dtEff) && [...this.lawForm.values()].some((v) => !v)) {
3222
- // A terminal read by its flux whose response is lost to round-off (a floating gate's
3223
- // charging, below its metal's G Δη): once more, read by the change (see circuitLaws in
3224
- // src/lawRows.js).
3225
- this.u.set(this.uPrev);
3226
- this.uLo.set(this.uPrevLo);
3227
- this.termV.set(this.termVPrev);
3228
- if (opts.guess) this._guess(opts.guess);
3229
- for (const cs of this.lawForm.keys()) this.lawForm.set(cs, true);
3230
- const again = this.newton(dtEff, opts);
3231
- result = { ...again, iterations: result.iterations + again.iterations };
3232
- }
3233
- } finally {
3234
- this.combining = false;
3235
- }
3236
- result.bdf = bdf;
3237
- if (result.converged) {
3238
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
3239
- if (this.history.length > 3) this.history.length = 3;
3240
- this.time = tEnd;
3241
- this.lastDt = dtEff;
3242
- this.contactDOld = this.contactDStart;
3243
- this.portQOld = this.portQStart;
3244
- this._accumulateBoundaryIntake(dtEff, cN);
3245
- // (In time, the state is no longer the steady solve's that a continuation would start from.)
3246
- if (!this.steady) {
3247
- this.solvedV = undefined;
3248
- this.stepped = true;
3249
- }
3250
- } else {
3251
- this.u.set(this.uPrev);
3252
- this.uLo.set(this.uPrevLo);
3253
- this.termV.set(this.termVPrev);
3254
- this.sourceTime = this.steady ? this.steadyAt : this.time;
3255
- this.computeConcentrations();
3256
- }
3257
- this.sourceBefore = false;
3258
- return result;
3188
+ return stepFn(this, dt, opts);
3259
3189
  }
3260
3190
 
3261
- // Newton's unknowns set to a guess (e.g. extrapolated from the history). Only its unknowns: the
3262
- // other slots never change (see _activeSlots), and among them is a gauge, an insertion host's
3263
- // φ̂, that nothing pulls back. Extrapolated through steps that grow, it wandered off
3264
- // geometrically, 147 to 8000 thermal units in a battery's first 100 s and 1e11 by 1e5 s, where
3265
- // its species' levels (η − zφ̂, summed back by the host's statistics) lost their digits to it.
3266
- _guess(guess) {
3267
- const { u, uLo } = this;
3268
- for (const k of this.fullOf) {
3269
- u[k] = guess[k];
3270
- uLo[k] = 0;
3271
- }
3272
- }
3273
-
3274
- /**
3275
- * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
3276
- * estimated against an explicit predictor through the previous states, and controlled to
3277
- * `tol` thermal units per step in every potential (φ̂, each η, a surface's coverages as η). The first
3278
- * step is checked by step doubling. Stops early when `budgetMs` of wall time is used, so an
3279
- * animation can call it once per frame; the step size carries over between calls.
3280
- */
3191
+ // Adaptive time stepping to tEnd (src/timeSteps.js).
3281
3192
  integrate(tEnd, opts = {}) {
3282
- // tEnd is an absolute time. Not a number at all is an error; a NaN (a first animation frame's
3283
- // clock, or a time read from a field that doesn't exist) does nothing, and says so.
3284
- 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)`;
3285
- if (typeof tEnd !== 'number') throw new SolverError(`advance(tEnd): ${absolute}; got ${tEnd}`);
3286
- 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}` };
3287
- const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
3288
- const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
3289
- const start = clock();
3290
- const sample = this._probes(opts.probes);
3291
- const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
3292
- let steps = 0, rejected = 0, iterations = 0, failed = false;
3293
- // Already there (an animation frame with no time to add): nothing to do, and the step size
3294
- // carried to the next call stays as it was. A time already passed, or reached again outside
3295
- // an animation, is most likely advance(dt) read as a duration: say so.
3296
- if (!(tEnd > this.time)) {
3297
- const relative = tEnd < this.time || (this.time > 0 && opts.budgetMs === undefined);
3298
- 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)` } : {}) };
3299
- }
3300
- const t0 = this.time;
3301
- let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
3302
- if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
3303
- // The shortest step worth trying: round-off relative to the time, or the device's fastest
3304
- // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
3305
- const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
3306
- const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
3307
- const firstErrs = []; // the first step's error at each size tried (see below)
3308
- const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
3309
- while (this.time < tEnd) {
3310
- if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
3311
- // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
3312
- // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
3313
- // strictly neutral device, fails outright. Round-off of the time and negligible beside the
3314
- // interval asked for: by the time alone, a GITT pulse of 0.01 s at t = 1e9 s was skipped
3315
- // whole (the time moved, nothing else did), and from 1e10 s 17% of a pulse train's charge.
3316
- if (tEnd - this.time <= 1e-10 * Math.abs(tEnd) && tEnd - this.time <= 1e-9 * (tEnd - t0)) {
3317
- this.time = tEnd;
3318
- break;
3319
- }
3320
- const remaining = tEnd - this.time;
3321
- let h = Math.min(dt, dtMax);
3322
- let clamped = h >= remaining * (1 - 1e-9);
3323
- if (clamped) h = remaining;
3324
- // Land on the waveforms' breakpoints, where a source's slope jumps.
3325
- const tb = this.terms.reduce((m, t) => Math.min(m, nextBreakpoint(t.drive.src, this.time)), Infinity);
3326
- const atBreak = tb - this.time <= h * (1 + 1e-9);
3327
- if (atBreak) {
3328
- h = tb - this.time;
3329
- clamped = true;
3330
- }
3331
- this.landing = atBreak ? tb : clamped ? tEnd : undefined;
3332
- const snap = this._snapshot();
3333
- let err, p, half = null;
3334
- if (this.history.length > 0) firstErrs.length = 0;
3335
- if (this.history.length === 0) {
3336
- // First step: backward Euler, checked against two half steps (whose result is kept).
3337
- const full = this.step(h);
3338
- iterations += full.iterations;
3339
- if (full.converged) {
3340
- const uFull = Float64Array.from(this.u);
3341
- this._restore(snap);
3342
- const a = this.step(h / 2);
3343
- // The first half step is kept too, so it goes in the trace (a jump's charge is in it).
3344
- half = a.converged ? { t: this.time, current: this._terminalCurrent(), voltage: this.termV[1] - this.termV[0], probes: sample?.read() } : null;
3345
- const b = a.converged ? this.step(h / 2) : a;
3346
- iterations += a.iterations + (b === a ? 0 : b.iterations);
3347
- if (b.converged) {
3348
- err = this._errorNorm(uFull);
3349
- p = 1;
3350
- }
3351
- }
3352
- if (err === undefined) {
3353
- this._restore(snap);
3354
- rejected++;
3355
- dt = h / 4;
3356
- if (dt < floor) { failed = true; break; }
3357
- continue;
3358
- }
3359
- } else {
3360
- // (Order 2 once three consistent states can check it.)
3361
- const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, h) });
3362
- iterations += r.iterations;
3363
- if (!r.converged) {
3364
- rejected++;
3365
- dt = h / 4;
3366
- if (dt < floor) { failed = true; break; }
3367
- continue;
3368
- }
3369
- p = this._predict(pred, r.bdf);
3370
- err = this._errorNorm(pred) * p.scale;
3371
- p = p.order;
3372
- }
3373
- // A first step whose error doesn't shrink with it: a jump in a level held at a boundary
3374
- // (a bath's, after a voltage step) starts a profile self-similar in x/√t, so a first step
3375
- // of any length errs alike, down to the grid's own diffusion time, below Newton's reach.
3376
- // Three tries in a row, each within a factor 2 of the last, say so: the error is the
3377
- // jump's, not the step's. Take the step (backward Euler damps it as it should) and grow.
3378
- if (half && err > tol) firstErrs.push(err);
3379
- 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]));
3380
- if (err > tol && !jump) {
3381
- this._restore(snap);
3382
- rejected++;
3383
- dt = h * factor(err, p);
3384
- continue;
3385
- }
3386
- steps++;
3387
- // The state the first step started from needn't satisfy the algebraic equations (φ in a
3388
- // strictly neutral region, an interface's unknowns, after a start or a jump), and they
3389
- // jump in the first instant; a predictor through it would see that jump as error on every
3390
- // step after. The history starts from the half step.
3391
- if (half) this.history.length = 1;
3392
- dt = clamped ? Math.max(dt, h * factor(err, p)) : h * factor(err, p);
3393
- if (atBreak) this.history = []; // the solution's slope jumps here: restart the order
3394
- if (half) {
3395
- trace.t.push(half.t);
3396
- trace.current.push(half.current);
3397
- trace.voltage.push(half.voltage);
3398
- if (sample) sample.push(half.probes);
3399
- }
3400
- trace.t.push(this.time);
3401
- trace.current.push(this._terminalCurrent());
3402
- trace.voltage.push(this.termV[1] - this.termV[0]);
3403
- if (sample) sample.push(sample.read());
3404
- }
3405
- this.dtNext = dt;
3406
- this.landing = undefined;
3407
- // Stopped short, other than on the caller's wall-time budget: say where, and why.
3408
- let stopped;
3409
- const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3410
- // (What usually does it, most likely first. Round 11's agents read the old list, which led
3411
- // with a drive's jump, as blaming a modest current pulse: a host emptied at its surface, and
3412
- // an electrode at open circuit with no double layer, held only by a slow reaction.)
3413
- 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.`;
3414
- 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.`;
3415
- return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3416
- }
3417
-
3418
- // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
3419
- // the two most recent history entries: a starting guess for Newton. (In offsets built from the
3420
- // steps' own lengths, never differences of absolute times: a second into a run, after a jump
3421
- // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
3422
- _extrapolate(out, h) {
3423
- const [e0, e1] = this.history;
3424
- const u = this.u;
3425
- if (!e0) return undefined;
3426
- if (!e1) {
3427
- const l = h / e0.dt;
3428
- for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
3429
- return out;
3430
- }
3431
- const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
3432
- for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
3433
- return out;
3434
- }
3435
-
3436
- // Explicit predictor at the new time through the previous states (quadratic after BDF2,
3437
- // linear after backward Euler), and the factor turning |u − pred| into the local error.
3438
- _predict(pred, bdf) {
3439
- const [e0, e1, e2] = this.history;
3440
- const h = e0.dt;
3441
- if (!e1) {
3442
- pred.set(e0.u);
3443
- return { scale: 1, order: 1 };
3444
- }
3445
- const hp = e1.dt;
3446
- if (bdf && e2) {
3447
- const hpp = e2.dt;
3448
- // Lagrange through the three states before at the new time (offsets as in _extrapolate).
3449
- const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
3450
- for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
3451
- const w = h / hp;
3452
- const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
3453
- const Cp = h * (h + hp) * (h + hp + hpp);
3454
- return { scale: Cc / (Cc + Cp), order: 2 };
3455
- }
3456
- const l0 = (h + hp) / hp, l1 = 1 - l0;
3457
- for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
3458
- // Backward Euler's error against a linear predictor; after a BDF2 step without enough
3459
- // history this overestimates, which is safe.
3460
- return { scale: bdf ? 1 : h / (2 * h + hp), order: 1 };
3193
+ return integrateFn(this, tEnd, opts);
3461
3194
  }
3462
3195
 
3463
3196
  // Probes for a transient's trace: { x, species, quantity: 'c' | 'V', region } read after every
@@ -3531,23 +3264,6 @@ export class Solver {
3531
3264
  };
3532
3265
  }
3533
3266
 
3534
- // Largest difference from ref among the potentials that carry state, in thermal units.
3535
- _errorNorm(ref) {
3536
- const { n, M, u } = this;
3537
- let mx = 0;
3538
- for (let g = 0; g < this.nNodes; g++) {
3539
- const b = this.blockOfNode[g];
3540
- if (!this.phiUndefined[g]) mx = Math.max(mx, Math.abs(u[b * M] - ref[b * M]));
3541
- for (let i = 0; i < n; i++) {
3542
- if (this.present[g * n + i]) mx = Math.max(mx, Math.abs(u[b * M + 1 + i] - ref[b * M + 1 + i]));
3543
- }
3544
- const k = this.surfPort[g]; // an electrode surface's coverages, as η
3545
- 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]));
3546
- }
3547
- 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)
3548
- return mx;
3549
- }
3550
-
3551
3267
  // Start from another solver's state for the same device on another grid (a refined one): each
3552
3268
  // node's unknowns interpolated linearly in x within its region, each face's copied, and the
3553
3269
  // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
@@ -4303,6 +4019,7 @@ export class Solver {
4303
4019
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
4304
4020
  // a change of drive).
4305
4021
  solveSteady(opts = {}) {
4022
+ this.violent = this.history.length === 0 && !this.stepped ? violentStart(this) : []; // (for a failure to say: see src/timeSteps.js)
4306
4023
  this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4307
4024
  this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
4308
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[];