driftlet 0.11.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solver.js CHANGED
@@ -261,17 +261,45 @@ export class SolverError extends Error {
261
261
  }
262
262
  }
263
263
 
264
+ // Lagrange weights at x through the points s0, s1, s2.
265
+ function lagrange3(x, s0, s1, s2) {
266
+ 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))];
267
+ }
268
+
269
+ // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
270
+ function gateRate(law, V) {
271
+ const { rate, midpoint, scale } = law;
272
+ if (law.type === 'exp') {
273
+ // (held finite where Newton's iterates stray to absurd voltages: β_m at −13 V would overflow)
274
+ const y = (V - midpoint) / scale, v = rate * Math.exp(Math.min(y, 700));
275
+ return [v, y > 700 ? 0 : v / scale];
276
+ }
277
+ if (law.type === 'sigmoid') {
278
+ const e = Math.exp((midpoint - V) / scale), d = 1 + e;
279
+ return Number.isFinite(e) ? [rate / d, (rate * e) / (scale * d * d)] : [0, 0];
280
+ }
281
+ // expLinear: rate·y/(1 − e^−y), smooth through y = 0 (rate there) and → rate·y for large y
282
+ const y = (V - midpoint) / scale;
283
+ if (Math.abs(y) < 1e-6) return [rate * (1 + y / 2), rate / (2 * scale)];
284
+ if (y < -700) return [0, 0];
285
+ const em = -Math.expm1(-y), ey = Math.exp(-y); // 1 − e^−y, e^−y
286
+ return [(rate * y) / em, (rate * (em - y * ey)) / (em * em * scale)];
287
+ }
288
+
264
289
  export class Solver {
265
290
  constructor(model) {
266
291
  this.model = model;
267
292
  const { grid, species, materials, regions, contacts } = model;
268
293
  const n = species.length;
269
294
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
270
- // one per face reaction (its rate), as many as the busiest face has.
271
- const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
295
+ // one per face reaction (its rate) and one per gate (its fraction open), as many as the
296
+ // busiest face has.
297
+ const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length + itf.gates.length), 0);
272
298
  // (and at an electrode port's nodes, one per surface species: its coverage's η)
273
299
  const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
274
- const M = n + 1 + Math.max(nRx, nSurf);
300
+ // (and a membrane port's gates, after them: each one's fraction open)
301
+ const nPortSlots = model.ports.reduce((m, port) => Math.max(m, port.surface.length + port.gates.length), 0);
302
+ const M = n + 1 + Math.max(nRx, nPortSlots);
275
303
  const nNodes = grid.nNodes;
276
304
  const nFaces = regions.length - 1;
277
305
  const nB = nNodes + nFaces;
@@ -306,6 +334,20 @@ export class Solver {
306
334
  // The bare fraction θ₀, directly (1 − Σθ cancels as θ → 1); 1 where there's no surface.
307
335
  this.th0 = new Float64Array(nNodes).fill(1);
308
336
  this.th0Old = new Float64Array(nNodes).fill(1);
337
+ // Gates: each one's slot (at a face, after its reaction rates; at a membrane port's nodes, after
338
+ // the surface's coverages) and value at the step's start. Faces' first, then ports', node by
339
+ // node through each window.
340
+ this.gatePort = new Int32Array(nNodes).fill(-1);
341
+ model.ports.forEach((port, k) => port.gates.length > 0 && port.nodes.forEach((g) => (this.gatePort[g] = k)));
342
+ this.gateList = [
343
+ ...model.interfaces.flatMap((itf, f) => itf.gates.map((gate, q) => ({ f, q, gate, o: this.blockOfFace[f] * M + 1 + n + itf.reactions.length + q }))),
344
+ ...model.ports.flatMap((port, k) => Array.from(port.nodes).flatMap((g) => port.gates.map((gate, q) => ({ port: k, g, q, gate, o: this.blockOfNode[g] * M + 1 + n + port.surface.length + q })))),
345
+ ];
346
+ this.gateOld = new Float64Array(this.gateList.length);
347
+ this.gateIndex = new Int32Array(nFaces); // face f's first gate in gateList
348
+ let j = 0;
349
+ for (let f = 0; f < nFaces; j += model.interfaces[f].gates.length, f++) this.gateIndex[f] = j;
350
+ this.portGateIndex = model.ports.map((port) => ((j += port.nodes.length * port.gates.length), j - port.nodes.length * port.gates.length));
309
351
 
310
352
  // Per-node material data, flattened [g·n + i].
311
353
  this.present = new Uint8Array(nNodes * n);
@@ -574,12 +616,14 @@ export class Solver {
574
616
  for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
575
617
  const k = this.surfPort[g];
576
618
  if (k >= 0) for (let q = 0; q < model.ports[k].surface.length; q++) active[b * M + 1 + n + q] = 1;
619
+ const kg = this.gatePort[g], pg = model.ports[kg];
620
+ if (kg >= 0) for (let q = 0; q < pg.gates.length; q++) active[b * M + 1 + n + pg.surface.length + q] = 1;
577
621
  }
578
622
  model.interfaces.forEach((itf, f) => {
579
623
  const bf = this.blockOfFace[f];
580
624
  active[bf * M] = itf.phi.type === 'neutral' ? 0 : 1;
581
625
  for (let i = 0; i < n; i++) active[bf * M + 1 + i] = itf.links[i].type === 'blocked' ? 0 : 1;
582
- for (let k = 0; k < itf.reactions.length; k++) active[bf * M + 1 + n + k] = 1;
626
+ for (let k = 0; k < itf.reactions.length + itf.gates.length; k++) active[bf * M + 1 + n + k] = 1;
583
627
  });
584
628
  const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
585
629
  for (let b = 0; b < nB; b++) for (let r = 0; r < M; r++) if (active[b * M + r]) loc[b * M + r] = sizes[b]++;
@@ -1176,7 +1220,7 @@ export class Solver {
1176
1220
  /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
1177
1221
  contactEta(side, i) {
1178
1222
  const link = this.model.contacts[side].species[i];
1179
- if (link.type !== 'equilibrium') return NaN;
1223
+ if (link.type !== 'equilibrium' && link.type !== 'velocity') return NaN;
1180
1224
  const z = this.z[i];
1181
1225
  return z === 0 ? link.mu / this.model.RT : (z * (this.termV[side === 'left' ? 0 : 1] + link.offset)) / this.VT;
1182
1226
  }
@@ -1393,6 +1437,11 @@ export class Solver {
1393
1437
  });
1394
1438
  if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1395
1439
  }
1440
+ // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
1441
+ for (const gt of this.gateList) {
1442
+ const { gate, o } = gt, V = this._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
1443
+ u[o] = a + b > 0 ? a / (a + b) : 0;
1444
+ }
1396
1445
  // A floating electrode spread through a port (without a double layer) starts where its
1397
1446
  // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
1398
1447
  // the start's composition, not level with a held terminal, which can be volts away and pass
@@ -1601,6 +1650,7 @@ export class Solver {
1601
1650
  const { grid, materials, regions } = model;
1602
1651
  sys.clear();
1603
1652
  res.fill(0);
1653
+ this.dtNow = dt;
1604
1654
  if (this.lin) this.lin.reset(1 / dt);
1605
1655
  this.computeConcentrations();
1606
1656
 
@@ -1752,7 +1802,7 @@ export class Solver {
1752
1802
  // A start-of-step net charge (round-off in a solved state) isn't carried over: the neutrality
1753
1803
  // row holds the new state neutral.
1754
1804
  _chargeRows(dt) {
1755
- const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY;
1805
+ const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY, live = this._liveTerms();
1756
1806
  const { A, B, C, sizes, offA, offB, offC } = sys, vol = this.model.grid.vol, last = this.nB - 1;
1757
1807
  for (const g of this.chargeNodes) {
1758
1808
  const b = this.blockOfNode[g], lb = b * M, m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < last ? sizes[b + 1] : 0;
@@ -1776,7 +1826,7 @@ export class Solver {
1776
1826
  for (let q = 0, o = offB[b] + lk * m, s = offB[b] + li * m; q < m; q++) B[o + q] += w * B[s + q];
1777
1827
  for (let q = 0, o = offC[b] + lk * mn, s = offC[b] + li * mn; q < mn; q++) C[o + q] += w * C[s + q];
1778
1828
  res[rk] += w * res[ri];
1779
- for (let t = 0; t < termB.length; t++) termB[t][rk] += w * termB[t][ri];
1829
+ for (const t of live) termB[t][rk] += w * termB[t][ri];
1780
1830
  if (this.lin) this.lin.combine(rk, ri, w);
1781
1831
  }
1782
1832
  // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
@@ -1787,7 +1837,7 @@ export class Solver {
1787
1837
  for (let r = 0, o = offB[b]; r < m; r++) B[o + r * m + p] += zi * B[o + r * m + li];
1788
1838
  if (b > 0) for (let r = 0, mr = mp, o = offC[b - 1]; r < mr; r++) C[o + r * m + p] += zi * C[o + r * m + li];
1789
1839
  if (b < last) for (let r = 0, mr = mn, o = offA[b + 1]; r < mr; r++) A[o + r * m + p] += zi * A[o + r * m + li];
1790
- for (let t = 0; t < termC.length; t++) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1840
+ for (const t of live) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1791
1841
  }
1792
1842
  // Storage on the other charged balances, and neutrality: no φ̂' terms, exactly.
1793
1843
  const v = vol[g], oB = offB[b];
@@ -2036,14 +2086,21 @@ export class Solver {
2036
2086
  // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
2037
2087
  // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
2038
2088
  // and it's exactly zero where μ̄ is level.
2039
- const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], P = itf.links[i].P;
2089
+ const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], link = itf.links[i];
2040
2090
  const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
2041
2091
  const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
2042
2092
  const E = Math.expm1(-deta); // η_R − η_L
2093
+ // Gated: P × Π x^p, each x taken in [0, 1] (Newton's iterates can stray past its ends).
2094
+ const gated = link.gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
2095
+ const P = link.P * (gated.length ? this._open(gated, gx) : 1);
2043
2096
  const gBc = P * bernoulli(d) * this.c[kl];
2044
2097
  const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
2045
2098
  res[R[o]] = u[o] + gBc * E;
2046
2099
  this._j(bf, r, bf, r, 1);
2100
+ for (const [q, p] of gated) {
2101
+ const dO = this._dOpen(gated, q, p, gx);
2102
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, link.P * bernoulli(d) * this.c[kl] * E * dO);
2103
+ }
2047
2104
  this._j(bf, r, bL, r, -gBc);
2048
2105
  this._j(bf, r, bR, r, gBc * (E + 1));
2049
2106
  if (zi !== 0) {
@@ -2051,12 +2108,18 @@ export class Solver {
2051
2108
  this._j(bf, r, bR, 0, -zi * dNdd);
2052
2109
  }
2053
2110
  } else {
2054
- // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
2055
- const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
2111
+ // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F); gated,
2112
+ // G times Π x^p
2113
+ const gated = itf.links[i].gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
2114
+ const gG0 = (itf.links[i].G * VT) / (z[i] * z[i] * F), gG = gG0 * (gated.length ? this._open(gated, gx) : 1);
2056
2115
  res[R[o]] = u[o] - gG * deta;
2057
2116
  this._j(bf, r, bf, r, 1);
2058
2117
  this._j(bf, r, bL, r, -gG);
2059
2118
  this._j(bf, r, bR, r, gG);
2119
+ for (const [q, p] of gated) {
2120
+ const dO = this._dOpen(gated, q, p, gx);
2121
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, -gG0 * deta * dO);
2122
+ }
2060
2123
  }
2061
2124
  res[R[bL * M + r]] += Af * u[o];
2062
2125
  this._j(bL, r, bf, r, Af);
@@ -2064,6 +2127,46 @@ export class Solver {
2064
2127
  this._j(bR, r, bf, r, -Af);
2065
2128
  }
2066
2129
  itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
2130
+ if (itf.gates.length > 0) this._faceGates(f, bf, bL, bR);
2131
+ }
2132
+
2133
+ // Full index of face f's gate q (its fraction open).
2134
+ _gateSlot(f, q) {
2135
+ return this.blockOfFace[f] * this.M + 1 + this.n + this.model.interfaces[f].reactions.length + q;
2136
+ }
2137
+
2138
+ // The voltage a gate follows: across its face, or across its port's capacitance at its node.
2139
+ _gateVoltage(gt) {
2140
+ return gt.port === undefined ? this._faceVoltage(gt.f) : this._portVoltage(gt.port, gt.g);
2141
+ }
2142
+
2143
+ // The voltage across port k's capacitance at node g, φ − (V − zeroCharge) (V): a membrane's,
2144
+ // inside (the region) minus outside.
2145
+ _portVoltage(k, g) {
2146
+ const { M, u, uLo, VT } = this, b = this.blockOfNode[g], port = this.model.ports[k];
2147
+ return VT * (u[b * M] + uLo[b * M]) - (this.termV[2 + k] - port.capacitance.zeroCharge);
2148
+ }
2149
+
2150
+ // The voltage across face f, φ_right − φ_left (V).
2151
+ _faceVoltage(f) {
2152
+ const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
2153
+ return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]));
2154
+ }
2155
+
2156
+ // Face f's gates, each a row (x − x_old)/dt = α(1 − x) − βx in the voltage across the face.
2157
+ _faceGates(f, bf, bL, bR) {
2158
+ const { res, VT } = this, R = this.rix, itf = this.model.interfaces[f], dt = this.dtNow;
2159
+ const V = this._faceVoltage(f), k0 = 1 + this.n + itf.reactions.length;
2160
+ itf.gates.forEach((gate, q) => {
2161
+ const o = this._gateSlot(f, q), x = this.u[o];
2162
+ const [a, da] = gateRate(gate.alpha, V), [b, db] = gateRate(gate.beta, V);
2163
+ const store = Number.isFinite(dt) ? 1 / dt : 0, j = this.gateIndex[f] + q;
2164
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - b * x);
2165
+ this._j(bf, k0 + q, bf, k0 + q, store + a + b);
2166
+ const dV = -(da * (1 - x) - db * x) * VT; // per unit φ̂_R, and minus that per unit φ̂_L
2167
+ this._j(bf, k0 + q, bR, 0, dV);
2168
+ this._j(bf, k0 + q, bL, 0, -dV);
2169
+ });
2067
2170
  }
2068
2171
 
2069
2172
  // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
@@ -2345,16 +2448,26 @@ export class Solver {
2345
2448
  }
2346
2449
  }
2347
2450
 
2451
+ // The terminals whose ∂res/∂V (termB) and ∂I/∂x (termC) are kept, exactly: the floating ones,
2452
+ // which the Newton solve borders on, or all of them (allTerminals: for the impedance, which reads
2453
+ // its terminal's). A held terminal's are otherwise left as they fall (written but not kept
2454
+ // consistent), which spares a device with dozens of ports (an axon's nodes of Ranvier) the cost
2455
+ // of carrying each one's two dense vectors through every assembly.
2456
+ _liveTerms() {
2457
+ if (this.allTerminals) return this._allTerms ?? (this._allTerms = this.terms.map((_, k) => k));
2458
+ return this.floating;
2459
+ }
2460
+
2348
2461
  // Every terminal's terms: its voltage held or floating, how the residual depends on it
2349
2462
  // (termB), its current into the device (termI) and how that depends on the state (termC).
2350
2463
  _terminals(dt) {
2351
2464
  this._refreshSources();
2352
- for (let k = 0; k < this.terms.length; k++) {
2465
+ for (const k of this._liveTerms()) {
2353
2466
  this.termB[k].fill(0);
2354
2467
  this.termC[k].fill(0);
2355
- this.termDI[k] = 0;
2356
- this.termI[k] = 0;
2357
2468
  }
2469
+ this.termDI.fill(0);
2470
+ this.termI.fill(0);
2358
2471
  this.qg.fill(0);
2359
2472
  this.qgSlope.fill(0);
2360
2473
  this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
@@ -2412,13 +2525,17 @@ export class Solver {
2412
2525
  flux[i] += res[R[o]];
2413
2526
  this._captureRow(b, r, zF, C);
2414
2527
  this._replaceRow(b, r);
2528
+ for (const Bk of this.termB) Bk[R[o]] = 0; // (other ports' terms in the row replaced)
2415
2529
  this._j(b, r, b, r, 1);
2416
2530
  res[R[o]] = -deta;
2417
2531
  B[R[o]] += -z[i] / VT;
2418
2532
  continue;
2419
2533
  }
2420
- // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η)
2421
- const kk = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2534
+ // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η). Gated, G times
2535
+ // Π x^p, the gates at this node (each taken in [0, 1]).
2536
+ const kk0 = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2537
+ const gated = link.gates ?? [], q0 = 1 + n + port.surface.length;
2538
+ const open = gated.length ? this._open(gated, (q) => u[b * M + q0 + q]) : 1, kk = kk0 * open;
2422
2539
  res[R[o]] -= v * kk * deta;
2423
2540
  this._j(b, r, b, r, v * kk);
2424
2541
  flux[i] += v * kk * deta;
@@ -2427,6 +2544,12 @@ export class Solver {
2427
2544
  C[R[o]] += -zF * v * kk;
2428
2545
  this.termDI[k] += (zF * v * kk * z[i]) / VT;
2429
2546
  }
2547
+ for (const [q, p] of gated) {
2548
+ const d = this._dOpen(gated, q, p, (qq) => u[b * M + q0 + qq]);
2549
+ if (d === 0) continue;
2550
+ this._j(b, r, b, q0 + q, -v * kk0 * deta * d);
2551
+ if (z[i] !== 0) C[R[b * M + q0 + q]] += zF * v * kk0 * deta * d;
2552
+ }
2430
2553
  }
2431
2554
  }
2432
2555
  if (holds) {
@@ -2435,6 +2558,7 @@ export class Solver {
2435
2558
  }
2436
2559
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2437
2560
  if (port.capacitance) this._portCapacitance(port, k, dt);
2561
+ if (port.gates.length > 0) this._portGates(port, k, dt);
2438
2562
  if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2439
2563
  }
2440
2564
 
@@ -2503,6 +2627,38 @@ export class Solver {
2503
2627
  // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2504
2628
  // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2505
2629
  // contact sets is left to the contact.
2630
+ // A membrane port's gates, at each node of its window: (x − x_old)/dt = α(1 − x) − βx in the
2631
+ // voltage across its capacitance there, φ − (V − zeroCharge).
2632
+ _portGates(port, k, dt) {
2633
+ const { n, res, VT } = this, R = this.rix, B = this.termB[k], store = Number.isFinite(dt) ? 1 / dt : 0, q0 = 1 + n + port.surface.length;
2634
+ port.nodes.forEach((g, w) => {
2635
+ const b = this.blockOfNode[g], V = this._portVoltage(k - 2, g);
2636
+ port.gates.forEach((gate, q) => {
2637
+ const o = b * this.M + q0 + q, x = this.u[o], j = this.portGateIndex[k - 2] + w * port.gates.length + q;
2638
+ const [a, da] = gateRate(gate.alpha, V), [bb, db] = gateRate(gate.beta, V);
2639
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - bb * x);
2640
+ this._j(b, q0 + q, b, q0 + q, store + a + bb);
2641
+ const s = -(da * (1 - x) - db * x); // ∂/∂V_m, with V_m = V_T φ̂ − V + zeroCharge
2642
+ this._j(b, q0 + q, b, 0, s * VT);
2643
+ B[R[o]] += -s;
2644
+ });
2645
+ });
2646
+ }
2647
+
2648
+ // Π x^p over a link's gates (each x taken in [0, 1]), and its derivative in gate q.
2649
+ _open(gated, x) {
2650
+ let open = 1;
2651
+ for (const [q, p] of gated) open *= powi(Math.min(1, Math.max(0, x(q))), p);
2652
+ return open;
2653
+ }
2654
+ _dOpen(gated, q, p, x) {
2655
+ const xq = x(q);
2656
+ if (!(xq > 0 && xq < 1)) return 0;
2657
+ let others = 1;
2658
+ for (const [q2, p2] of gated) if (q2 !== q) others *= powi(Math.min(1, Math.max(0, x(q2))), p2);
2659
+ return p * powi(xq, p - 1) * others;
2660
+ }
2661
+
2506
2662
  _portCapacitance(port, k, dt) {
2507
2663
  const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2508
2664
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
@@ -2597,7 +2753,7 @@ export class Solver {
2597
2753
  }
2598
2754
 
2599
2755
  // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2600
- // links (neutral species): N_in = k (μ_out − μ)/RT.
2756
+ // links (neutral species): N_in = k (μ_out − μ)/RT; velocity links, v (c_eq − c).
2601
2757
  for (let i = 0; i < n; i++) {
2602
2758
  const link = ct.species[i];
2603
2759
  const o = b * M + 1 + i;
@@ -2609,6 +2765,16 @@ export class Solver {
2609
2765
  res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
2610
2766
  this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
2611
2767
  B[R[o]] += (-Ac * link.G) / (z[i] * F);
2768
+ } else if (link.type === 'velocity') {
2769
+ // N_in = v c (e^(η_out − η) − 1): v (c_eq − c), c_eq in equilibrium with the outside.
2770
+ const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - (u[o] + uLo[o]), 700));
2771
+ const N = Ac * link.v * ci * (E - 1), d = this.dA;
2772
+ res[R[o]] -= N;
2773
+ d.fill(0);
2774
+ this._dlnc(g, i, 1, d);
2775
+ for (let s = 0; s <= n; s++) if (d[s] !== 0) this._j(b, 1 + i, b, s, -N * d[s]);
2776
+ this._j(b, 1 + i, b, 1 + i, Ac * link.v * ci * E);
2777
+ if (z[i] !== 0) B[R[o]] += (-Ac * link.v * ci * E * z[i]) / VT;
2612
2778
  }
2613
2779
  }
2614
2780
 
@@ -2738,7 +2904,7 @@ export class Solver {
2738
2904
 
2739
2905
  // Scale every row by its largest Jacobian entry (in place, residual too).
2740
2906
  _equilibrate() {
2741
- const { nB, sys, res, termB } = this;
2907
+ const { nB, sys, res, termB } = this, live = this._liveTerms();
2742
2908
  const { A, B, C, sizes, offA, offB, offC, offX } = sys;
2743
2909
  for (let b = 0; b < nB; b++) {
2744
2910
  const m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < nB - 1 ? sizes[b + 1] : 0;
@@ -2758,7 +2924,7 @@ export class Solver {
2758
2924
  for (let k = 0; k < m; k++) B[ob + k] *= s;
2759
2925
  for (let k = 0; k < mn; k++) C[oc + k] *= s;
2760
2926
  res[offX[b] + r] *= s;
2761
- for (let k = 0; k < termB.length; k++) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
2927
+ for (const k of live) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
2762
2928
  }
2763
2929
  }
2764
2930
  }
@@ -2883,6 +3049,7 @@ export class Solver {
2883
3049
  const k = this.surfPort[g]; // an electrode surface's coverages, as η
2884
3050
  if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
2885
3051
  }
3052
+ for (const gt of this.gateList) mx = Math.max(mx, Math.abs(delta[R[gt.o]])); // a gate's fraction open
2886
3053
  return mx;
2887
3054
  }
2888
3055
 
@@ -3104,6 +3271,7 @@ export class Solver {
3104
3271
  this.termVPrev.set(this.termV);
3105
3272
  this.computeConcentrations();
3106
3273
  const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
3274
+ const gN = Float64Array.from(this.gateList, (gt) => this.u[gt.o] + this.uLo[gt.o]);
3107
3275
  // Contact displacement before the step: as it was at the end of the previous step (under
3108
3276
  // the parameters then), so a gate-voltage change shows up as displacement current.
3109
3277
  if (!this.contactDEnd || !this.portQEnd) {
@@ -3124,6 +3292,7 @@ export class Solver {
3124
3292
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
3125
3293
  for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
3126
3294
  for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
3295
+ for (let k = 0; k < gN.length; k++) this.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
3127
3296
  this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
3128
3297
  this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
3129
3298
  dtEff = dt / a0;
@@ -3131,6 +3300,7 @@ export class Solver {
3131
3300
  this.cOld.set(cN);
3132
3301
  this.thOld.set(thN);
3133
3302
  this.th0Old.set(th0N);
3303
+ this.gateOld.set(gN);
3134
3304
  this.contactDStart = DN;
3135
3305
  this.portQStart = QN;
3136
3306
  }
@@ -3162,7 +3332,7 @@ export class Solver {
3162
3332
  }
3163
3333
  result.bdf = bdf;
3164
3334
  if (result.converged) {
3165
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, D: DN, Q: QN });
3335
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
3166
3336
  if (this.history.length > 3) this.history.length = 3;
3167
3337
  this.time = tEnd;
3168
3338
  this.lastDt = dtEff;
@@ -3255,7 +3425,7 @@ export class Solver {
3255
3425
  }
3256
3426
  } else {
3257
3427
  // (Order 2 once three consistent states can check it.)
3258
- const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, this.time + h) });
3428
+ const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, h) });
3259
3429
  iterations += r.iterations;
3260
3430
  if (!r.converged) {
3261
3431
  rejected++;
@@ -3301,24 +3471,28 @@ export class Solver {
3301
3471
  }
3302
3472
  this.dtNext = dt;
3303
3473
  this.landing = undefined;
3304
- return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace };
3474
+ // Stopped short, other than on the caller's wall-time budget: say where, and why.
3475
+ let stopped;
3476
+ const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3477
+ if (failed) stopped = `${at}: a step didn't converge even when shortened to ${dt.toExponential(1)} s. A drive's jump too big for one step (ramp it), a driven current the device can't keep passing (a diode's reverse recovery past its storage time: stop there), or a device the steady solves also find hard.`;
3478
+ 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.`;
3479
+ return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3305
3480
  }
3306
3481
 
3307
- // Quadratic (or linear) extrapolation of the state to time t, from the current state and the
3308
- // two most recent history entries: a starting guess for Newton.
3309
- _extrapolate(out, t) {
3482
+ // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
3483
+ // the two most recent history entries: a starting guess for Newton. (In offsets built from the
3484
+ // steps' own lengths, never differences of absolute times: a second into a run, after a jump
3485
+ // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
3486
+ _extrapolate(out, h) {
3310
3487
  const [e0, e1] = this.history;
3311
- const t0 = this.time, u = this.u;
3488
+ const u = this.u;
3312
3489
  if (!e0) return undefined;
3313
3490
  if (!e1) {
3314
- const l = (t - t0) / (t0 - e0.t);
3491
+ const l = h / e0.dt;
3315
3492
  for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
3316
3493
  return out;
3317
3494
  }
3318
- const t1 = e0.t, t2 = e1.t;
3319
- const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
3320
- const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
3321
- const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
3495
+ const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
3322
3496
  for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
3323
3497
  return out;
3324
3498
  }
@@ -3327,7 +3501,7 @@ export class Solver {
3327
3501
  // linear after backward Euler), and the factor turning |u − pred| into the local error.
3328
3502
  _predict(pred, bdf) {
3329
3503
  const [e0, e1, e2] = this.history;
3330
- const t = this.time, h = e0.dt;
3504
+ const h = e0.dt;
3331
3505
  if (!e1) {
3332
3506
  pred.set(e0.u);
3333
3507
  return { scale: 1, order: 1 };
@@ -3335,18 +3509,15 @@ export class Solver {
3335
3509
  const hp = e1.dt;
3336
3510
  if (bdf && e2) {
3337
3511
  const hpp = e2.dt;
3338
- // Lagrange through (t0, u0), (t1, u1), (t2, u2) at t.
3339
- const t0 = e0.t, t1 = e1.t, t2 = e2.t;
3340
- const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
3341
- const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
3342
- const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
3512
+ // Lagrange through the three states before at the new time (offsets as in _extrapolate).
3513
+ const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
3343
3514
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
3344
3515
  const w = h / hp;
3345
3516
  const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
3346
3517
  const Cp = h * (h + hp) * (h + hp + hpp);
3347
3518
  return { scale: Cc / (Cc + Cp), order: 2 };
3348
3519
  }
3349
- const l0 = (t - e1.t) / (e0.t - e1.t), l1 = 1 - l0;
3520
+ const l0 = (h + hp) / hp, l1 = 1 - l0;
3350
3521
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
3351
3522
  // Backward Euler's error against a linear predictor; after a BDF2 step without enough
3352
3523
  // history this overestimates, which is safe.
@@ -3363,9 +3534,22 @@ export class Solver {
3363
3534
  const fail = (m) => {
3364
3535
  throw new DeviceError(m);
3365
3536
  };
3366
- if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
3537
+ if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }, or { interface, species } / { interface, gate }');
3367
3538
  const plan = probes.map((p, k) => {
3368
3539
  const path = `advance: probes[${k}]`;
3540
+ if (p?.interface !== undefined) {
3541
+ // At a face: a species' flux through it (mol/(m²·s), toward +x), or a gate's fraction open.
3542
+ const f = p.interface, itf = this.model.interfaces[f];
3543
+ if (!(Number.isInteger(f) && itf)) fail(`${path}.interface: no interface ${JSON.stringify(f)} (they're numbered from 0, left to right)`);
3544
+ if (p.gate !== undefined) {
3545
+ const q = itf.gates.findIndex((gt) => gt.name === p.gate);
3546
+ if (q < 0) fail(`${path}.gate: interfaces[${f}] has no gate ${JSON.stringify(p.gate)}${itf.gates.length ? ` (it has ${itf.gates.map((gt) => gt.name).join(', ')})` : ''}`);
3547
+ return { o: this._gateSlot(f, q) };
3548
+ }
3549
+ const i = species.findIndex((sp) => sp.name === p.species);
3550
+ if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p.species)}`);
3551
+ return { o: this.blockOfFace[f] * this.M + 1 + i };
3552
+ }
3369
3553
  const quantity = p?.quantity ?? 'c';
3370
3554
  if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
3371
3555
  const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
@@ -3397,7 +3581,7 @@ export class Solver {
3397
3581
  };
3398
3582
  return {
3399
3583
  out,
3400
- read: () => plan.map((q) => (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1)),
3584
+ read: () => plan.map((q) => (q.o !== undefined ? (this.loc[q.o] < 0 ? 0 : this.u[q.o]) : (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1))),
3401
3585
  push: (values) => values.forEach((v, k) => out[k].push(v)),
3402
3586
  };
3403
3587
  }
@@ -3415,6 +3599,7 @@ export class Solver {
3415
3599
  const k = this.surfPort[g]; // an electrode surface's coverages, as η
3416
3600
  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]));
3417
3601
  }
3602
+ 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)
3418
3603
  return mx;
3419
3604
  }
3420
3605
 
@@ -3458,6 +3643,7 @@ export class Solver {
3458
3643
  cOld: Float64Array.from(this.cOld),
3459
3644
  thOld: Float64Array.from(this.thOld),
3460
3645
  th0Old: Float64Array.from(this.th0Old),
3646
+ gateOld: Float64Array.from(this.gateOld),
3461
3647
  time: this.time,
3462
3648
  lastDt: this.lastDt,
3463
3649
  atSteady: this.atSteady,
@@ -3480,6 +3666,7 @@ export class Solver {
3480
3666
  this.cOld.set(s.cOld);
3481
3667
  this.thOld.set(s.thOld);
3482
3668
  this.th0Old.set(s.th0Old);
3669
+ this.gateOld.set(s.gateOld);
3483
3670
  this.time = s.time;
3484
3671
  this.lastDt = s.lastDt;
3485
3672
  this.atSteady = s.atSteady;
@@ -3515,7 +3702,17 @@ export class Solver {
3515
3702
  * Z = δV/δI with I into the device (positive real part for a passive device).
3516
3703
  * @param {ArrayLike<number>} frequencies Hz
3517
3704
  */
3518
- impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3705
+ impedance(frequencies, opts) {
3706
+ const was = this.allTerminals;
3707
+ this.allTerminals = true; // (it reads its terminal's ∂res/∂V and ∂I/∂x)
3708
+ try {
3709
+ return this._impedance(frequencies, opts);
3710
+ } finally {
3711
+ this.allTerminals = was;
3712
+ }
3713
+ }
3714
+
3715
+ _impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3519
3716
  const { M, nB, sys, VT } = this;
3520
3717
  const kT = this.terms.findIndex((t) => t.name === terminal);
3521
3718
  if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
@@ -3527,6 +3724,7 @@ export class Solver {
3527
3724
  this.cOld.set(this.c);
3528
3725
  this.thOld.set(this.th);
3529
3726
  this.th0Old.set(this.th0);
3727
+ this.gateList.forEach((gt, j) => (this.gateOld[j] = this.u[gt.o]));
3530
3728
  this.assemble(Infinity);
3531
3729
  this.contactDStart = { ...this.contactD };
3532
3730
  this.portQStart = Float64Array.from(this.portQ);
@@ -3540,7 +3738,15 @@ export class Solver {
3540
3738
  for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
3541
3739
  const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
3542
3740
  const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
3543
- const cuts = this._currentCuts();
3741
+ // Where the measured contact's current may be read across a segment: anywhere without
3742
+ // ports; with them, only between it and the nearest window (a port's exchange takes current
3743
+ // in or out along it). Never for a port's own terminal.
3744
+ let cuts = kT < 2 ? this._currentCuts() : null;
3745
+ if (cuts && this.model.ports.length > 0) {
3746
+ const windows = this.model.ports.flatMap((p) => Array.from(p.nodes));
3747
+ const lo = Math.min(...windows), hi = Math.max(...windows);
3748
+ cuts = cuts.filter((cut) => (kT === 0 ? cut.s + 1 <= lo : cut.s >= hi));
3749
+ }
3544
3750
  // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
3545
3751
  // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
3546
3752
  // flux to round-off. So the dilute kernels' terms are kept in difference form (see
@@ -3709,7 +3915,7 @@ export class Solver {
3709
3915
  Ii += im;
3710
3916
  for (let j = 0; j < Nc; j++) err += (Math.abs(c[j]) + w * Math.abs(cs[j])) * (eps * Math.hypot(p.r[j], p.i[j]) + p.floor);
3711
3917
  }
3712
- if (!cuts || kT >= 2) return { Ir, Ii, err };
3918
+ if (!cuts) return { Ir, Ii, err };
3713
3919
  const sgn = kT === 0 ? 1 : -1;
3714
3920
  for (const cut of cuts) {
3715
3921
  let re = 0, im = 0, e = 0;
@@ -3749,7 +3955,8 @@ export class Solver {
3749
3955
  // taken at its edges; and what's left, small, solved for to GMRES's floor, its error now
3750
3956
  // relative to itself. An electrolyte at its open circuit needs it: the response to its
3751
3957
  // terminal is nearly such a shift, and its current the slope of levels uniform to 1e-14,
3752
- // more than a double can hold. (Not with ports, whose exchange sees the shift.)
3958
+ // more than a double can hold. (A port's exchange sees the shift: its window's rows are
3959
+ // kept with the edges'.) So does a MOS capacitor's back contact, its channel held by a port.
3753
3960
  if (K === 0 && cuts && err > 1e-2 * Math.hypot(Ir, Ii)) {
3754
3961
  const [er, ei] = this._regionShift(yr, yi);
3755
3962
  const tr = new Float64Array(Nc), ti = new Float64Array(Nc);
@@ -3839,8 +4046,9 @@ export class Solver {
3839
4046
  return [er, ei];
3840
4047
  }
3841
4048
 
3842
- // Compact rows a region's uniform shift can touch: faces, the nodes beside them, and the two
3843
- // ends (whatever holds a level from outside). Inside a region, every row is unchanged by it.
4049
+ // Compact rows a region's uniform shift can touch: faces, the nodes beside them, the two ends
4050
+ // and the ports' windows (whatever holds a level from outside). Elsewhere inside a region,
4051
+ // every row is unchanged by it.
3844
4052
  _regionEdgeRows() {
3845
4053
  const { M, loc, nB } = this, R = this.rix;
3846
4054
  const edge = new Uint8Array(this.sys.size);
@@ -3856,16 +4064,17 @@ export class Solver {
3856
4064
  mark(b);
3857
4065
  mark(b + 1);
3858
4066
  }
4067
+ for (const port of this.model.ports) for (const g of port.nodes) mark(this.blockOfNode[g]);
3859
4068
  return edge;
3860
4069
  }
3861
4070
 
3862
- // The cuts where the impedance may read a small-signal current (null where the current
3863
- // differs between cuts: a device with ports). Each is the linearised total current toward +x
3864
- // through one segment of an ideal region, Σ (g + iω d)·δu over the unknowns idx: the
3865
- // Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the displacement's.
4071
+ // The cuts where the impedance may read a small-signal current. Each is the linearised total
4072
+ // current toward +x through one segment s of an ideal region, Σ (g + iω d)·δu over the
4073
+ // unknowns idx: the Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the
4074
+ // displacement's. Without ports it's the same through every cut; with them, only between a
4075
+ // contact and the nearest port's window (see _impedance).
3866
4076
  _currentCuts() {
3867
4077
  const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
3868
- if (model.ports.length > 0) return null;
3869
4078
  const { grid, materials, regions } = model;
3870
4079
  const cuts = [];
3871
4080
  for (let r = 0; r < regions.length; r++) {
@@ -3905,7 +4114,7 @@ export class Solver {
3905
4114
  }
3906
4115
  if (terms.size === 0) continue;
3907
4116
  const idx = [...terms.keys()];
3908
- cuts.push({ idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
4117
+ cuts.push({ s, idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
3909
4118
  }
3910
4119
  }
3911
4120
  return cuts;
@@ -3955,6 +4164,7 @@ export class Solver {
3955
4164
  // Jacobian are left partial (the next assemble() starts afresh). Concentrations must be current.
3956
4165
  _assembleBookkeeping(dt) {
3957
4166
  const { model } = this;
4167
+ this.dtNow = dt;
3958
4168
  const { grid, materials, regions } = model;
3959
4169
  this.res.fill(0);
3960
4170
  for (let r = 0; r < regions.length; r++) {
@@ -4083,7 +4293,7 @@ export class Solver {
4083
4293
  // pseudo-transient ramp: one direct attempt first.
4084
4294
  const quick = (canContinue || this.hasGeneration) && direct && opts.continuation !== false;
4085
4295
  let r = this._solveSteady(quick ? { ...opts, maxSteps: 1 } : opts);
4086
- if (r.converged) this.solvedV = target;
4296
+ if (r.converged) this.solvedV = [level, target];
4087
4297
  if (r.converged) return r;
4088
4298
  // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
4089
4299
  // nothing, each solve warm from the last.
@@ -4097,7 +4307,7 @@ export class Solver {
4097
4307
  const g = this._generationContinuation(opts, r);
4098
4308
  dimmer = this.dimmer;
4099
4309
  if (g.converged) {
4100
- this.solvedV = target;
4310
+ this.solvedV = [level, target];
4101
4311
  return g;
4102
4312
  }
4103
4313
  r = g;
@@ -4138,17 +4348,23 @@ export class Solver {
4138
4348
  this.computeConcentrations();
4139
4349
  };
4140
4350
  restart();
4141
- // Ramp from the last converged voltage when the state is that solution (a warm start),
4142
- // otherwise from level terminals.
4143
- const from = this.solvedV !== undefined ? this.solvedV : level;
4144
- const c = this._continuation(opts, from, target, r, from !== level);
4351
+ // Ramp from the contacts' voltages at the last converged solve when the state is that
4352
+ // solution (a warm start): the left one first where it moved, the right held where it was,
4353
+ // then the right. Otherwise from level terminals.
4354
+ const solved = this.solvedV;
4355
+ let c;
4356
+ if (solved) {
4357
+ c = solved[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, solved[0], level, r, true, solved[1]);
4358
+ // (Ramping the right from level terminals, solve there first: a sweep's start.)
4359
+ if (c.converged) c = this._continuation(opts, 1, solved[1], target, c, c.ramped || solved[1] !== level);
4360
+ } else c = this._continuation(opts, 1, level, target, r, false);
4145
4361
  if (c.converged) {
4146
- this.solvedV = target;
4362
+ this.solvedV = [level, target];
4147
4363
  return c;
4148
4364
  }
4149
4365
  restart();
4150
4366
  r = this._solveSteady(opts); // the full pseudo-transient ramp
4151
- if (r.converged) this.solvedV = target;
4367
+ if (r.converged) this.solvedV = [level, target];
4152
4368
  return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
4153
4369
  }
4154
4370
 
@@ -4229,13 +4445,15 @@ export class Solver {
4229
4445
  // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
4230
4446
  // limit: its bordered row 33 digits short), but a current-driven steady state is the held
4231
4447
  // one at the voltage that passes the target: found by held solves, by regula falsi
4232
- // (Illinois) within the bracket, to 1e-10 of the target.
4448
+ // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
4449
+ // current the march saw (a target of 0 is never met exactly).
4450
+ const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
4233
4451
  try {
4234
4452
  let side = 0;
4235
4453
  for (let it = 0; it < 60; it++) {
4236
4454
  const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
4237
4455
  if (!Number.isFinite(fm)) return fail();
4238
- if (Math.abs(fm) <= 1e-10 * Math.abs(target)) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4456
+ if (Math.abs(fm) <= 1e-10 * scale) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4239
4457
  if (Math.sign(fm) === Math.sign(fb)) {
4240
4458
  [Vb, fb] = [Vm, fm];
4241
4459
  if (side === -1) fa /= 2;
@@ -4293,12 +4511,18 @@ export class Solver {
4293
4511
  }
4294
4512
  }
4295
4513
 
4296
- _continuation(opts, level, target, r, warm) {
4514
+ // Terminal k (a contact) ramped from `level` to `target` through steady solves, each warm from
4515
+ // the last (`hold`: the other contact's voltage meanwhile, if not its own). A warm ramp that has
4516
+ // nowhere to go converges only if what it started from did (`ramped`): the state it carries
4517
+ // must be some ramp's solution, never just the one a failed solve left.
4518
+ _continuation(opts, k, level, target, r, warm, hold) {
4297
4519
  const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
4298
4520
  let V = level, dV = (target - level) / 8, steps = r.steps, iterations = r.iterations;
4299
4521
  const history = r.history.slice();
4522
+ if (warm && V === target) return { ...r, converged: r.ramped === true, steps, iterations, history, ramped: r.ramped };
4300
4523
  try {
4301
- this.sourceOverride.set(1, V);
4524
+ this.sourceOverride.set(k, V);
4525
+ if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4302
4526
  let q;
4303
4527
  if (!warm) {
4304
4528
  q = this._solveSteady(opts);
@@ -4320,7 +4544,7 @@ export class Solver {
4320
4544
  while (V !== target) {
4321
4545
  const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4322
4546
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4323
- this.sourceOverride.set(1, next);
4547
+ this.sourceOverride.set(k, next);
4324
4548
  q = this._solveSteady(sub);
4325
4549
  steps += q.steps;
4326
4550
  iterations += q.iterations;
@@ -4337,9 +4561,10 @@ export class Solver {
4337
4561
  if (Math.abs(dV) < 1e-6 * Math.abs(target - level)) return { converged: false, steps, iterations, history };
4338
4562
  }
4339
4563
  }
4340
- return { converged: true, steps, iterations, history };
4564
+ return { converged: true, steps, iterations, history, ramped: true };
4341
4565
  } finally {
4342
- this.sourceOverride.delete(1);
4566
+ this.sourceOverride.delete(k);
4567
+ if (hold !== undefined) this.sourceOverride.delete(1 - k);
4343
4568
  this._refreshSources();
4344
4569
  }
4345
4570
  }