driftlet 1.3.0 → 1.3.3

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/src/start.js CHANGED
@@ -27,18 +27,24 @@
27
27
  // plus each face's dipole. In a region where a level-fixed charged species responds to φ̂,
28
28
  // φ̂ is chosen for neutrality (a bisection; node by node where a profile varies);
29
29
  // otherwise it's the running φ̂, or where a conductor on the left or an electrode spread
30
- // through the region has a reaction that can balance, the φ̂ that balances it. Where
31
- // neutrality is out of reach (c0's amounts leave a charge no held level balances), the
32
- // running φ̂, and a strictly neutral region is noted in s.unbalanced (for a transient that
33
- // fails from it to say why). A strictly
30
+ // through the region has a reaction that can balance, the φ̂ that balances it (by bisection;
31
+ // a trial a non-ideal model refuses counts its ideal charge, which has the sign there). Where
32
+ // neutrality is out of reach (c0's amounts leave a charge no held level balances): in a
33
+ // strictly neutral region the running φ̂, the region noted in s.unbalanced (for a transient
34
+ // that fails from it to say why); otherwise the φ̂ nearest the running one, toward less
35
+ // charge, where the charge is at most ε V_T/(F L²) (L the region's length: what it holds with
36
+ // a thermal voltage across it), or where it stops falling (a fixed charge). A strictly
34
37
  // neutral region (ε = 0) with every charged species' amount given must have a neutral c0:
35
38
  // an error otherwise. A non-ideal material's composition comes from its statistics
36
39
  // (materialAt).
37
- // 2. A face between two strictly neutral ideal regions, its φ type neutral, whose links hold
38
- // some charged species in equilibrium: its two nodes are set to that equilibrium (each
39
- // passing species' μ̄ equal across it, the left node neutral, each passing species' amount
40
- // over the two nodes kept; the jump in φ̂ between them the one unknown), everything else at
41
- // them unchanged in concentration.
40
+ // 2. Marks the state cold (s.coldStart). equilibrateFaces(s), which the first time step from it
41
+ // calls (src/timeSteps.js) and a steady solve doesn't: a face between two strictly neutral
42
+ // ideal regions, its φ type neutral, whose links hold some charged species in equilibrium,
43
+ // its two nodes set to that equilibrium, its t = 0⁺ (each passing species' μ̄ equal across
44
+ // it, the left node neutral, each passing species' amount over the two nodes kept; the jump
45
+ // in φ̂ between them the one unknown), everything else at them unchanged in concentration;
46
+ // s.coldStart false. A solve that converges, a warm start and a carried state set it as
47
+ // their state is.
42
48
  // 3. An insertion host (a φ-free material, not a conductor): its φ̂ and every η shifted by z·Δ so that its first
43
49
  // charged species shared with a neighbour across a face starts at that neighbour's level.
44
50
  // 4. A floating contact facing a held one: V = the held V + the difference between the two
@@ -55,17 +61,18 @@
55
61
  // pass the current it's driven at, or behind a resistance (V_src − V)/R, by bisection.
56
62
  //
57
63
  // Invariants (each says what a reviewer should be able to check here):
58
- // 1. A species given by its amount starts at c0 (or its profile) at every node, but for the
59
- // two nodes of a face equilibrated in step 2, where its amount over the two is kept.
64
+ // 1. A species given by its amount starts at c0 (or its profile) at every node; at a face
65
+ // equilibrated by step 2 (a transient's first step), its amount over the two nodes is kept.
60
66
  // 2. A species given by its level starts at the level of what feeds it, as step 1 orders them.
61
67
  // 3. Where a level-fixed charged species responds, each node starts neutral (Σ z c + the fixed
62
- // charge = 0) to the bisection's tolerance, unless neutrality is out of reach (step 1); a
68
+ // charge = 0) to the bisection's tolerance, unless neutrality is out of reach (step 1: then
69
+ // its charge at most ε V_T/(F L²), or the least φ̂ gives, to the bisection's tolerance); a
63
70
  // strictly neutral region given by amounts is neutral by its c0, checked.
64
71
  // 4. Steps 2 and 3 change no concentration but as they say: step 3 is a gauge (φ̂ and each η by
65
72
  // z·Δ), step 2 moves the passing species between a face's two nodes only.
66
73
  // 5. Each floating terminal's V is set by the last of steps 4, 6, 7, 9 that applies to it;
67
74
  // held terminals' V are untouched.
68
- // 6. Only u, uLo, termV, s.unbalanced and what computeConcentrations derives are written; the
75
+ // 6. Only u, uLo, termV, s.unbalanced, s.coldStart and what computeConcentrations derives are written; the
69
76
  // result depends on the model, every terminal's voltage and s.time, deterministically.
70
77
  // Known, and not an invariant (a cold review; see ROADMAP): the gauge (φ̂ and each η by z·Δ)
71
78
  // runs from the left, so behind a closed or floating left end the start sits away from a right
@@ -77,7 +84,7 @@
77
84
  // Tests: every test (each device starts here); test/transient.test.js on what a start must be.
78
85
 
79
86
  import { gateRate } from './boundaries.js';
80
- import { FARADAY } from './constants.js';
87
+ import { EPS0, FARADAY } from './constants.js';
81
88
  import { sourceAt } from './device.js';
82
89
  import { profileAt, SolverError } from './solver.js';
83
90
 
@@ -163,10 +170,22 @@ export function initFromComposition(s) {
163
170
  const zeta = new Float64Array(n), cc = new Float64Array(n);
164
171
  const charge = (ph) => {
165
172
  if (!mat.ideal) {
166
- materialAt(s, mat, reg.background, eta, mode, cFix, ph, zeta, cc);
167
- let q = reg.fixedCharge / FARADAY;
168
- for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
169
- return q;
173
+ // A model can refuse a trial φ̂ far out, where one ion is many orders denser than the rest
174
+ // (Debye–Hückel's limiting law threw out of the start, for KCl at 10 mM, its neutral φ̂
175
+ // 57 units from where the search began): there the ideal charge has the sign, and the
176
+ // search goes on by it.
177
+ let refused = false;
178
+ try {
179
+ materialAt(s, mat, reg.background, eta, mode, cFix, ph, zeta, cc);
180
+ } catch (err) {
181
+ if (!(err instanceof SolverError)) throw err;
182
+ refused = true;
183
+ }
184
+ if (!refused) {
185
+ let q = reg.fixedCharge / FARADAY;
186
+ for (let i = 0; i < n; i++) if (mat.present[i]) q += z[i] * cc[i];
187
+ return q;
188
+ }
170
189
  }
171
190
  let q = reg.fixedCharge / FARADAY;
172
191
  for (let i = 0; i < n; i++) {
@@ -244,13 +263,39 @@ export function initFromComposition(s) {
244
263
  while (charge(lo) < 0 && lo > -1e4) lo -= 2 * (hi - lo);
245
264
  while (charge(hi) > 0 && hi < 1e4) hi += 2 * (hi - lo);
246
265
  // Out of reach (every responding carrier has one sign and nothing balances it, as in an
247
- // undoped layer that holds only holes): keep the running φ̂ rather than run it to ±∞.
266
+ // undoped layer that holds only holes): not run to ±∞, but where the charge is no more than
267
+ // the region holds with a thermal voltage across it, ε V_T/(F L²), or as little as it gets
268
+ // (a fixed charge left). Kept at the running φ̂ (a contact's level), a hole-only layer 100
269
+ // nm thick started full of the anode's 1e27 m⁻³ holes, 7 kV of space charge: with traps in
270
+ // it, every step failed from there.
248
271
  if (charge(lo) < 0 || charge(hi) > 0) {
249
272
  // In a strictly neutral region that's a start that can't be: the amounts c0 gives leave
250
273
  // a charge no level-held species can balance. Noted, for a transient that fails from it
251
274
  // to say so (a steady solve, its levels from the contacts, can still find the state).
252
- if (mat.epsr === 0) s.unbalanced.push(r);
253
- return ph;
275
+ if (mat.epsr === 0) {
276
+ s.unbalanced.push(r);
277
+ return ph;
278
+ }
279
+ const L = grid.x[grid.regionEnd[r]] - grid.x[grid.regionStart[r]];
280
+ const most = (mat.epsr * EPS0 * model.RT) / (FARADAY * FARADAY * L * L);
281
+ const q0 = charge(ph), dir = q0 > 0 ? 1 : -1; // (the charge falls as φ̂ rises)
282
+ if (!(Math.abs(q0) > most)) return ph;
283
+ let a = ph, qa = Math.abs(q0);
284
+ for (let step = 1; step < 1e4; step *= 2) {
285
+ let b = a + dir * step;
286
+ const qb = Math.abs(charge(b));
287
+ if (qb <= most) {
288
+ for (let it = 0; it < 100 && Math.abs(b - a) > 1e-9; it++) {
289
+ const m = 0.5 * (a + b);
290
+ if (Math.abs(charge(m)) > most) a = m;
291
+ else b = m;
292
+ }
293
+ return b;
294
+ }
295
+ if (!(qb < qa * (1 - 1e-9))) return a; // (a fixed charge: as little as it gets)
296
+ [a, qa] = [b, qb];
297
+ }
298
+ return a;
254
299
  }
255
300
  for (let it = 0; it < 200 && hi - lo > 1e-12; it++) {
256
301
  const m = 0.5 * (lo + hi);
@@ -279,53 +324,6 @@ export function initFromComposition(s) {
279
324
  }
280
325
  }
281
326
  }
282
- // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
283
- // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
284
- // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
285
- // first step made to take that jump met an exactly singular pivot. Start there: the ions the
286
- // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
287
- // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
288
- // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
289
- // the two nodes' charge summing to what it was, zero.
290
- for (let f = 0; f < interfaces.length; f++) {
291
- const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
292
- const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
293
- if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
294
- const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = s.blockOfNode[gL] * M, bR = s.blockOfNode[gR] * M;
295
- const pass = [];
296
- for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
297
- if (!pass.some((i) => z[i] !== 0)) continue;
298
- const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
299
- const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
300
- const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
301
- let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
302
- for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
303
- // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
304
- const split = (D) =>
305
- pass.map((i, j) => {
306
- 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);
307
- return [K * cR, cR];
308
- });
309
- const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
310
- const D0 = u[bR] - u[bL];
311
- let lo = D0 - 1, hi = D0 + 1;
312
- while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
313
- while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
314
- if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
315
- for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
316
- const m = 0.5 * (lo + hi);
317
- if (qL(m) < 0) lo = m;
318
- else hi = m;
319
- }
320
- const D = 0.5 * (lo + hi), cs = split(D);
321
- const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
322
- u[bR] = u[bL] + D;
323
- 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];
324
- pass.forEach((i, j) => {
325
- u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
326
- u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
327
- });
328
- }
329
327
  // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
330
328
  // splits between its ion and its electrons. Start its ion level with the same ion's beside it
331
329
  // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
@@ -447,6 +445,69 @@ export function initFromComposition(s) {
447
445
  }
448
446
  s.termV[k] = 0.5 * (lo + hi);
449
447
  }
448
+ s.coldStart = true; // (until a solve or a step moves it: see equilibrateFaces)
449
+ }
450
+
451
+ /**
452
+ * The t = 0⁺ state at the faces between strictly neutral regions, taken by the first time step
453
+ * from a cold start (src/timeSteps.js) and not by a steady solve, which needs no t = 0⁺: there it
454
+ * was the worse start, its face nodes moved while the rest of a region stayed (a stack whose
455
+ * middle region started 2.2 V from its neighbour's level failed every steady solve, where the
456
+ * cold start converged in one).
457
+ */
458
+ export function equilibrateFaces(s) {
459
+ const { model, n, M, u, z } = s;
460
+ const { regions, interfaces, materials } = model;
461
+ const grid = model.grid;
462
+ s.coldStart = false;
463
+ // Two strictly neutral regions meeting at a face whose links hold ions in equilibrium: where
464
+ // c0 leaves the two sides out of it (a different fixed charge or immobile ion on each, so
465
+ // c₊c₋ differs across it), the face reaches it at t = 0⁺, before anything diffuses, and a
466
+ // first step made to take that jump met an exactly singular pivot. Start there: the ions the
467
+ // face passes shared between its two nodes in equilibrium (μ̄ equal across it), each one's
468
+ // amount over the two nodes kept, everything else as c0 has it. The one unknown is the jump
469
+ // in φ̂ across it (a Donnan potential), set by neutrality on one side; the other's follows,
470
+ // the two nodes' charge summing to what it was, zero.
471
+ for (let f = 0; f < interfaces.length; f++) {
472
+ const itf = interfaces[f], mL = materials[regions[f].material], mR = materials[regions[f + 1].material];
473
+ const strict = (m) => m.epsr === 0 && !m.conductor && !m.phiFree && m.ideal;
474
+ if (itf.phi.type !== 'neutral' || !strict(mL) || !strict(mR)) continue;
475
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1], bL = s.blockOfNode[gL] * M, bR = s.blockOfNode[gR] * M;
476
+ const pass = [];
477
+ for (let i = 0; i < n; i++) if (itf.links[i].type === 'equilibrium' && mL.present[i] && mR.present[i]) pass.push(i);
478
+ if (!pass.some((i) => z[i] !== 0)) continue;
479
+ const RT = model.RT, vL = grid.vol[gL], vR = grid.vol[gR];
480
+ const conc = (m, b, i) => m.cRef[i] * Math.exp(u[b + 1 + i] - m.mu0[i] / RT - z[i] * u[b]);
481
+ const amount = pass.map((i) => vL * conc(mL, bL, i) + vR * conc(mR, bR, i));
482
+ let qOther = regions[f].fixedCharge / FARADAY; // (the left node's charge from all it doesn't share)
483
+ for (let i = 0; i < n; i++) if (mL.present[i] && !pass.includes(i) && z[i] !== 0) qOther += z[i] * conc(mL, bL, i);
484
+ // At a jump D = φ̂_R − φ̂_L: c_L/c_R = (cRef_L/cRef_R)·exp((μ°_R − μ°_L)/RT + z D), the amount fixing both.
485
+ const split = (D) =>
486
+ pass.map((i, j) => {
487
+ 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);
488
+ return [K * cR, cR];
489
+ });
490
+ const qL = (D) => split(D).reduce((q, [cL], j) => q + z[pass[j]] * cL, qOther); // (increasing in D)
491
+ const D0 = u[bR] - u[bL];
492
+ let lo = D0 - 1, hi = D0 + 1;
493
+ while (qL(lo) > 0 && lo > D0 - 1e3) lo -= 2 * (hi - lo);
494
+ while (qL(hi) < 0 && hi < D0 + 1e3) hi += 2 * (hi - lo);
495
+ if (!(qL(lo) <= 0 && qL(hi) >= 0)) continue; // (nothing the face shares can balance it: leave c0)
496
+ for (let it = 0; it < 200 && hi - lo > 1e-13 * Math.max(1, Math.abs(D0)); it++) {
497
+ const m = 0.5 * (lo + hi);
498
+ if (qL(m) < 0) lo = m;
499
+ else hi = m;
500
+ }
501
+ const D = 0.5 * (lo + hi), cs = split(D);
502
+ const keep = Float64Array.from({ length: n }, (_, i) => (mR.present[i] ? conc(mR, bR, i) : 0)); // (the right node's, before φ̂ moves)
503
+ u[bR] = u[bL] + D;
504
+ 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];
505
+ pass.forEach((i, j) => {
506
+ u[bL + 1 + i] = Math.log(cs[j][0] / mL.cRef[i]) + mL.mu0[i] / RT + z[i] * u[bL];
507
+ u[bR + 1 + i] = Math.log(cs[j][1] / mR.cRef[i]) + mR.mu0[i] / RT + z[i] * u[bR];
508
+ });
509
+ }
510
+ s.computeConcentrations();
450
511
  }
451
512
 
452
513
  // A non-ideal material's composition at φ̂ = ph: level-fixed species (mode 1) at their η,
package/src/steady.js CHANGED
@@ -49,8 +49,14 @@
49
49
  // (this.stepped), on a direct device with continuation not off, the same again from a cold
50
50
  // start (initFromComposition), whose u and uLo become the stages' start (termV stays the
51
51
  // start's: a floating terminal keeps the voltage the transient left it at).
52
- // 2. Continuation: generation ramped up (_generationContinuation); a terminal driven by a
53
- // current, not bound (_bound), held at voltages instead (_floatingContinuation); with
52
+ // 2. Continuation: generation ramped up (_generationContinuation); terminals behind a
53
+ // resistance (_resistanceContinuation): one whose current Kirchhoff doesn't give, held at
54
+ // voltages marched to its load line's crossing (_floatingContinuation with a line); else, or
55
+ // where that fails, each held (at its source voltage, or where every other terminal is
56
+ // driven by a current at V_src − I R, Kirchhoff's I), then the resistances put back and the
57
+ // steady system solved from there (and where that fails for one terminal held by
58
+ // Kirchhoff, its load line); a terminal driven by a current, not bound (_bound), held at
59
+ // voltages instead (_floatingContinuation); with
54
60
  // canContinue, the right contact's voltage ramped to its target (_continuation): from the
55
61
  // voltages of the last converged solve (this.solvedV), the left contact's first where it
56
62
  // moved, else from level contacts, a cold start.
@@ -190,6 +196,7 @@ export class SteadySolves {
190
196
  this.steady = true; // (the steady system's rows; sources at the present time)
191
197
  this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
192
198
  this.drifted = null; // (an amount no row keeps, lost by a giant step: see _steadySteps)
199
+ this.modelError = null; // (a statistics model's refusal, for the failure: see newton)
193
200
  // A solve that fails leaves where the state was solved, and whether it was stepped, as they
194
201
  // were (Device.solve restores the state): a floating continuation's held solves had set them,
195
202
  // and the next held solve ramped warm from a voltage the state was never at, and failed.
@@ -252,6 +259,7 @@ export class SteadySolves {
252
259
  if (q.converged) {
253
260
  this.solvedV = [this.termV[0], this.termV[1]]; // (a contact's own: driven by a current, not the current)
254
261
  this.stepped = false;
262
+ this.coldStart = false; // (solved: no t = 0⁺ left for a transient to start from)
255
263
  }
256
264
  return q;
257
265
  };
@@ -289,6 +297,15 @@ export class SteadySolves {
289
297
  if (g.converged) return solved(g);
290
298
  r = g;
291
299
  }
300
+ // A terminal behind a resistance, cold: held at its source voltage first (with that its own
301
+ // continuations), then the resistance put back and solved from there (a diode behind 7 Ω·m²
302
+ // failed cold, and converged so; round 14's fuzzer, 4 of 9 such re-solves).
303
+ if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'V' && this.terms[k].drive.R > 0)) {
304
+ restart();
305
+ const q = this._resistanceContinuation(opts, r);
306
+ if (q.converged) return solved(q);
307
+ r = q;
308
+ }
292
309
  // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
293
310
  // voltage until the current crosses its target, and float it from there.
294
311
  if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
@@ -342,9 +359,10 @@ export class SteadySolves {
342
359
  // voltage instead, whose steady solves have their own continuation, the voltage marched from
343
360
  // the start's until the current crosses its target, the crossing narrowed by bisection, and
344
361
  // the terminal floated again from beside it.
345
- _floatingContinuation(opts, r) {
346
- const k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j));
347
- const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
362
+ // (With `line`, terminal k behind a resistance: the current its load line passes at V,
363
+ // (V_src − V)/R, the target, the march from the first held terminal's level.)
364
+ _floatingContinuation(opts, r, k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j)), line = null) {
365
+ const drive = this.terms[k].drive, target = line ? NaN : sourceAt(drive.src, this.time), want = (V) => (line ? line(V) : target);
348
366
  // Its drive put back: redrive() finds the laws again for it, with the local constraints'
349
367
  // references at zero, so they're taken from the state again (an immobile species held at 0
350
368
  // at every node: the floated solve failed, 80 steps, and the regula falsi rescued it at 6×).
@@ -363,11 +381,12 @@ export class SteadySolves {
363
381
  iterations += q.iterations ?? 0;
364
382
  history.push({ held: V, converged: q.converged });
365
383
  if (q.converged) seen.push([V, this.termI[k]]);
366
- return q.converged ? this.termI[k] - target : NaN;
384
+ return q.converged ? this.termI[k] - want(V) : NaN;
367
385
  };
368
386
  const fail = () => ({ converged: false, steps, iterations, history });
369
387
  const seen = []; // [V, I] of each held solve that converged
370
- let Va = this.termV[k], fa, Vb, fb;
388
+ const heldK = this.terms.findIndex((t) => t.drive.kind === 'V' && !(t.drive.R > 0));
389
+ let Va = line ? (heldK >= 0 ? this.termV[heldK] : 0) : this.termV[k], fa, Vb, fb;
371
390
  try {
372
391
  fa = hold(Va);
373
392
  if (!Number.isFinite(fa)) {
@@ -396,7 +415,7 @@ export class SteadySolves {
396
415
  }
397
416
  if (!Number.isFinite(fb) || (Math.sign(fb) === Math.sign(fa) && fa !== 0)) {
398
417
  // What the held voltages passed, at the two extremes reached.
399
- if (seen.length > 1) {
418
+ if (seen.length > 1 && !line) {
400
419
  seen.sort((a, b) => a[0] - b[0]);
401
420
  this.unreached = { terminal: this.terms[k].name, target, low: seen[0], high: seen.at(-1) };
402
421
  }
@@ -423,7 +442,7 @@ export class SteadySolves {
423
442
  // one at the voltage that passes the target: found by held solves, by regula falsi
424
443
  // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
425
444
  // current the march saw (a target of 0 is never met exactly).
426
- const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
445
+ const scale = Math.max(line ? 0 : Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
427
446
  try {
428
447
  let side = 0;
429
448
  for (let it = 0; it < 60; it++) {
@@ -446,6 +465,70 @@ export class SteadySolves {
446
465
  }
447
466
  }
448
467
 
468
+ // One terminal behind a resistance: its load line's crossing by held solves. Otherwise (or where
469
+ // that fails), each terminal behind a resistance held at its source voltage (R taken off; at
470
+ // V_src − I R where Kirchhoff gives I), solved, then the resistances put back and the steady
471
+ // system solved from that state.
472
+ _resistanceContinuation(opts, r) {
473
+ const ks = this.floating.filter((k) => this.terms[k].drive.kind === 'V' && this.terms[k].drive.R > 0);
474
+ const drives = ks.map((k) => this.terms[k].drive);
475
+ let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
476
+ const history = (r.history ?? []).slice();
477
+ // Where every other terminal is driven by a current, Kirchhoff gives the one behind R its
478
+ // current, and so the voltage it ends at, V_src − I R: held there, putting R back moves
479
+ // nothing (held at V_src, it had to slide the whole device by IR, and failed past volts).
480
+ const others = this.terms.every((t, j) => ks.includes(j) || t.drive.kind === 'I');
481
+ const at = (k) => {
482
+ const d = this.terms[k].drive, V = sourceAt(d.src, this.time);
483
+ if (!(others && ks.length === 1)) return V;
484
+ const I = -this.terms.reduce((t, tm, j) => (j === k ? t : t + sourceAt(tm.drive.src, this.time)), 0);
485
+ return V - I * d.R;
486
+ };
487
+ const start = [Float64Array.from(this.u), Float64Array.from(this.uLo), Float64Array.from(this.termV)];
488
+ const restore = () => {
489
+ this.u.set(start[0]);
490
+ this.uLo.set(start[1]);
491
+ this.termV.set(start[2]);
492
+ this.computeConcentrations();
493
+ };
494
+ // One terminal behind R, the rest not all driven by currents (so its current isn't known):
495
+ // its crossing with the load line, (V_src − V)/R, by held solves, as an open circuit's is
496
+ // with its target, first. Held at its source instead, the state is I R from the answer and
497
+ // the pseudo-transient relaxes it over R C at Newton's pace through that swing (a lit Schottky
498
+ // diode passing 0.9 A/m²: 0.05 s at 1 Ω·m², 10 s at 1e4, 105 s at 1e5, steps ∝ R).
499
+ const d = drives[0], Vs = sourceAt(d.src, this.time), line = (V) => (Vs - V) / d.R;
500
+ const byLine = ks.length === 1 && !others;
501
+ if (byLine) {
502
+ const q = this._floatingContinuation(opts, r, ks[0], line);
503
+ if (q.converged) return q;
504
+ restore(); // (from where this stage began, not where the failed one left it)
505
+ steps = q.steps ?? steps;
506
+ iterations = q.iterations ?? iterations;
507
+ history.splice(0, history.length, ...(q.history ?? []));
508
+ }
509
+ let heldOk = false;
510
+ try {
511
+ const held = ks.map(at);
512
+ ks.forEach((k, j) => (this.terms[k].drive = { ...this.terms[k].drive, src: { value: held[j] }, R: 0 }));
513
+ this.redrive();
514
+ const q = this._steadyFromHere(opts);
515
+ steps += q.steps ?? 0;
516
+ iterations += q.iterations ?? 0;
517
+ history.push({ resistancesOff: true, converged: q.converged });
518
+ heldOk = q.converged;
519
+ } finally {
520
+ ks.forEach((k, j) => (this.terms[k].drive = drives[j]));
521
+ this.redrive();
522
+ captureLocal(this);
523
+ }
524
+ const q = heldOk ? this._steadySteps(opts) : { converged: false, steps: 0, iterations: 0, history: [] };
525
+ const done = { ...q, steps: steps + q.steps, iterations: iterations + q.iterations, history: [...history, ...q.history] };
526
+ if (q.converged || ks.length !== 1 || byLine) return done;
527
+ // Where Kirchhoff's hold failed too, the load line.
528
+ restore();
529
+ return this._floatingContinuation(opts, done, ks[0], line);
530
+ }
531
+
449
532
  // Steady solves with the generation reactions' rates scaled from 1e-30 up to 1, ×100 a step
450
533
  // while they converge, smaller steps where they don't.
451
534
  _generationContinuation(opts, r) {
package/src/timeSteps.js CHANGED
@@ -21,6 +21,8 @@
21
21
  // (ports' charges) }: what BDF2 and the predictor need.
22
22
  //
23
23
  // step(s, dt, opts) → newton's result (+ bdf)
24
+ // From a cold start (s.coldStart, outside a steady solve), the faces between strictly neutral
25
+ // regions first set to their t = 0⁺ equilibrium (equilibrateFaces, src/start.js).
24
26
  // One step from s.time to tEnd = s.time + dt (or s.landing exactly, where it's within 1e-9·dt
25
27
  // of that). Backward Euler; BDF2 when opts.method is 'bdf2', a previous step exists and the
26
28
  // ratio ω = dt/dt_prev ≤ 2: then c* = b1·c_n − b2·c_{n−1} (b1 = (1+ω)/a0, b2 = ω²/((1+ω)a0),
@@ -85,6 +87,7 @@
85
87
 
86
88
  import { nextBreakpoint } from './device.js';
87
89
  import { SolverError } from './solver.js';
90
+ import { equilibrateFaces } from './start.js';
88
91
 
89
92
  // Lagrange weights at x through the points s0, s1, s2.
90
93
  function lagrange3(x, s0, s1, s2) {
@@ -120,6 +123,8 @@ export function advance(s, dt, opts, depth = 0) {
120
123
  * the start-of-step values, and dt/a0 replaces dt.
121
124
  */
122
125
  export function step(s, dt, opts = {}) {
126
+ // A transient's first step from a cold start begins at its t = 0⁺ (see equilibrateFaces).
127
+ if (s.coldStart && !s.steady) equilibrateFaces(s);
123
128
  // What the step overwrites before Newton runs, for a failed one to put back: the Solution read
124
129
  // after it takes its contacts' fluxes against the start-of-step values (a host emptied by its
125
130
  // current read its contact's Li⁺ flux 8× off at the same state).
@@ -262,6 +267,7 @@ export function integrate(s, tEnd, opts = {}) {
262
267
  const sample = s._probes(opts.probes);
263
268
  const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
264
269
  let steps = 0, rejected = 0, iterations = 0, failed = false;
270
+ s.modelError = null; // (a statistics model's refusal, for the failure: see newton)
265
271
  // Already there (an animation frame with no time to add): nothing to do, and the step size
266
272
  // carried to the next call stays as it was. A time already passed, or reached again outside
267
273
  // an animation, is most likely advance(dt) read as a duration: say so.
@@ -269,7 +275,7 @@ export function integrate(s, tEnd, opts = {}) {
269
275
  const relative = tEnd < s.time || (s.time > 0 && opts.budgetMs === undefined);
270
276
  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
277
  }
272
- const t0 = s.time;
278
+ const t0 = s.time, fromLayout = s.history.length === 0 && !s.stepped && !s.atSteady; // (c0's layout, not a solution)
273
279
  // A cold start whose floating double layers start uncharged far from the equilibrium of an ion
274
280
  // link beside them (a capacitive contact or port passing K⁺, its outside level set for the
275
281
  // charged steady state): the link closes the gap in the first instant, which can be far below
@@ -395,6 +401,12 @@ export function integrate(s, tEnd, opts = {}) {
395
401
  stopped = `${at}: the start can't be neutral in ${unbalanced.join(', ')}, strictly neutral (ε = 0): the amounts its c0 gives leave a net charge that no species held at a contact's level can balance, and a step can't move a stored amount at once. Give c0 that balances (with any fixed charge), or leave out of c0 the species a contact feeds, so that they start at its level.`;
396
402
  else if (failed && violent.length > 0)
397
403
  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.`;
404
+ else if (failed && s.modelError)
405
+ stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s, the state it reached refused by a statistics model: ${s.modelError}. The model doesn't hold there: a smaller concentration or drive, or a model for it (lattice for crowding).`;
406
+ else if (failed && fromLayout && steps === 0)
407
+ // (round 15's fuzzer: the pn template advanced from its c0, a step of 3e-24 s failing, 23 of 62
408
+ // plain diodes; nothing said to solve first)
409
+ stopped = `${at}: the first step did not converge even when shortened to ${dt.toExponential(1)} s, from the starting state, which is c0's layout rather than a solution (a junction's depletion not yet formed, say). For a transient from equilibrium, solve() first at the starting drives, then change what the experiment changes, then advance().`;
398
410
  else if (failed) stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. Look for: a host or surface driven to its limit (emptied or filled at its surface by a current it can no longer pass); an electrode at open circuit held only by a slow reaction, with no double-layer capacitance to keep its level (give its face one: phi capacitive); a drive's jump too big for one step (ramp it); a driven current the device cannot keep passing (a diode's reverse recovery past its storage time: stop there); or a device the steady solves also find hard.`;
399
411
  else if (s.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
400
412
  return { converged: !failed, done: s.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
@@ -4,8 +4,8 @@ export class SmallSignal {
4
4
  * gives (J + iωM)·δx + Σ B_k δV_k = rhs for the grid's rows, and (C_k + iωC′_k)·δx +
5
5
  * (∂I_k/∂V_k)·δV_k = δI_set,k for each floating terminal's circuit row, with J the steady
6
6
  * Jacobian and M the storage part (from J(dt) = J + M/dt), B_k = ∂res/∂V_k and C_k = ∂I_k/∂x.
7
- * At the measured terminal, a held voltage is perturbed (rhs = −B δV) and its current read
8
- * from C, or a driven current is perturbed and its voltage solved for. The other terminals
7
+ * At the measured terminal, its voltage is perturbed (rhs = −B δV) and its current read, held
8
+ * or driven by a current alike (one input impedance; see the contract). The other terminals
9
9
  * keep their drives: held ones at AC ground, driven ones at AC open circuit.
10
10
  * Z = δV/δI with I into the device (positive real part for a passive device).
11
11
  * @param {ArrayLike<number>} frequencies Hz
package/types/solver.d.ts CHANGED
@@ -273,6 +273,7 @@ export class Solver {
273
273
  };
274
274
  _warmFrom(src: any): void;
275
275
  solvedV: any;
276
+ coldStart: any;
276
277
  _snapshot(): {
277
278
  u: Float64Array<ArrayBuffer>;
278
279
  uLo: Float64Array<ArrayBuffer>;
@@ -299,6 +300,7 @@ export class Solver {
299
300
  history: any[];
300
301
  termV: Float64Array<ArrayBuffer>;
301
302
  sourceTime: number;
303
+ coldStart: any;
302
304
  };
303
305
  _restore(s: any): void;
304
306
  contactDEnd: any;
package/types/start.d.ts CHANGED
@@ -4,3 +4,11 @@
4
4
  * profile varies), or continued across the interface dipole if nothing there responds.
5
5
  */
6
6
  export function initFromComposition(s: any): void;
7
+ /**
8
+ * The t = 0⁺ state at the faces between strictly neutral regions, taken by the first time step
9
+ * from a cold start (src/timeSteps.js) and not by a steady solve, which needs no t = 0⁺: there it
10
+ * was the worse start, its face nodes moved while the rest of a region stayed (a stack whose
11
+ * middle region started 2.2 V from its neighbour's level failed every steady solve, where the
12
+ * cold start converged in one).
13
+ */
14
+ export function equilibrateFaces(s: any): void;
package/types/steady.d.ts CHANGED
@@ -29,6 +29,7 @@ export class SteadySolves {
29
29
  steady: boolean;
30
30
  steadyAt: any;
31
31
  drifted: any;
32
+ modelError: any;
32
33
  _solveSteadyAll(opts: any): any;
33
34
  atSteady: any;
34
35
  _steadyFromHere(opts: any): any;
@@ -36,9 +37,11 @@ export class SteadySolves {
36
37
  sourceTime: any;
37
38
  solvedV: any[];
38
39
  stepped: boolean;
40
+ coldStart: boolean;
39
41
  dimmer: boolean;
40
42
  _bound(k: any): any;
41
- _floatingContinuation(opts: any, r: any): any;
43
+ _floatingContinuation(opts: any, r: any, k?: any, line?: any): any;
44
+ _resistanceContinuation(opts: any, r: any): any;
42
45
  _generationContinuation(opts: any, r: any): {
43
46
  converged: boolean;
44
47
  steps: any;