driftlet 0.12.0 → 0.14.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,12 +261,18 @@ 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
+
264
269
  // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
265
270
  function gateRate(law, V) {
266
271
  const { rate, midpoint, scale } = law;
267
272
  if (law.type === 'exp') {
268
- const v = rate * Math.exp((V - midpoint) / scale);
269
- return [v, v / scale];
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];
270
276
  }
271
277
  if (law.type === 'sigmoid') {
272
278
  const e = Math.exp((midpoint - V) / scale), d = 1 + e;
@@ -1214,7 +1220,7 @@ export class Solver {
1214
1220
  /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
1215
1221
  contactEta(side, i) {
1216
1222
  const link = this.model.contacts[side].species[i];
1217
- if (link.type !== 'equilibrium') return NaN;
1223
+ if (link.type !== 'equilibrium' && link.type !== 'velocity') return NaN;
1218
1224
  const z = this.z[i];
1219
1225
  return z === 0 ? link.mu / this.model.RT : (z * (this.termV[side === 'left' ? 0 : 1] + link.offset)) / this.VT;
1220
1226
  }
@@ -2341,6 +2347,21 @@ export class Solver {
2341
2347
  return P * -Math.expm1(-(aHi + aLo));
2342
2348
  }
2343
2349
 
2350
+ // A bulk reaction's forward one-way rate at node g (mol/(m³·s)); its backward one is that
2351
+ // times e^{−a}, so their difference is bulkRate's.
2352
+ bulkForward(rx, g) {
2353
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
2354
+ const law = rx.srh?.[this.nodeMaterial[g]];
2355
+ if (law) {
2356
+ let a = rx.fixedA;
2357
+ for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
2358
+ return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).forward;
2359
+ }
2360
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2361
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2362
+ return P;
2363
+ }
2364
+
2344
2365
  // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
2345
2366
  // rate is read against (in equilibrium the net rate is their round-off).
2346
2367
  bulkOneWay(rx, g) {
@@ -2370,7 +2391,7 @@ export class Solver {
2370
2391
  const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
2371
2392
  const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
2372
2393
  const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
2373
- return { rate, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
2394
+ return { rate, forward: np / den, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
2374
2395
  }
2375
2396
 
2376
2397
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
@@ -2519,6 +2540,7 @@ export class Solver {
2519
2540
  flux[i] += res[R[o]];
2520
2541
  this._captureRow(b, r, zF, C);
2521
2542
  this._replaceRow(b, r);
2543
+ for (const Bk of this.termB) Bk[R[o]] = 0; // (other ports' terms in the row replaced)
2522
2544
  this._j(b, r, b, r, 1);
2523
2545
  res[R[o]] = -deta;
2524
2546
  B[R[o]] += -z[i] / VT;
@@ -2746,7 +2768,7 @@ export class Solver {
2746
2768
  }
2747
2769
 
2748
2770
  // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2749
- // links (neutral species): N_in = k (μ_out − μ)/RT.
2771
+ // links (neutral species): N_in = k (μ_out − μ)/RT; velocity links, v (c_eq − c).
2750
2772
  for (let i = 0; i < n; i++) {
2751
2773
  const link = ct.species[i];
2752
2774
  const o = b * M + 1 + i;
@@ -2758,6 +2780,16 @@ export class Solver {
2758
2780
  res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
2759
2781
  this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
2760
2782
  B[R[o]] += (-Ac * link.G) / (z[i] * F);
2783
+ } else if (link.type === 'velocity') {
2784
+ // N_in = v c (e^(η_out − η) − 1): v (c_eq − c), c_eq in equilibrium with the outside.
2785
+ const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - (u[o] + uLo[o]), 700));
2786
+ const N = Ac * link.v * ci * (E - 1), d = this.dA;
2787
+ res[R[o]] -= N;
2788
+ d.fill(0);
2789
+ this._dlnc(g, i, 1, d);
2790
+ for (let s = 0; s <= n; s++) if (d[s] !== 0) this._j(b, 1 + i, b, s, -N * d[s]);
2791
+ this._j(b, 1 + i, b, 1 + i, Ac * link.v * ci * E);
2792
+ if (z[i] !== 0) B[R[o]] += (-Ac * link.v * ci * E * z[i]) / VT;
2761
2793
  }
2762
2794
  }
2763
2795
 
@@ -3135,7 +3167,13 @@ export class Solver {
3135
3167
  const alpha = own > maxStep ? maxStep / own : 1;
3136
3168
  this._addToState(delta, -alpha);
3137
3169
  this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
3138
- if (alpha === 1 && step < tol) {
3170
+ // A time step's Newton contracts fast from its predictor (1e-2, 1e-9, 1e-15 thermal
3171
+ // units). The next update would be about θ = step/(the last one) times this one, so where
3172
+ // θ/(1 − θ) of it (Hairer and Wanner's estimate) is under tol, this update converged it:
3173
+ // the iteration that would only show that is skipped, a third of a typical step's work.
3174
+ const last = history[history.length - 2], theta = step / last;
3175
+ const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
3176
+ if (alpha === 1 && (step < tol || contracted)) {
3139
3177
  const done = { converged: true, iterations: it, history, residual: rmax };
3140
3178
  if (islandsOff(it, done)) continue;
3141
3179
  this.computeConcentrations();
@@ -3408,7 +3446,7 @@ export class Solver {
3408
3446
  }
3409
3447
  } else {
3410
3448
  // (Order 2 once three consistent states can check it.)
3411
- const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, this.time + h) });
3449
+ const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, h) });
3412
3450
  iterations += r.iterations;
3413
3451
  if (!r.converged) {
3414
3452
  rejected++;
@@ -3454,24 +3492,28 @@ export class Solver {
3454
3492
  }
3455
3493
  this.dtNext = dt;
3456
3494
  this.landing = undefined;
3457
- return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace };
3495
+ // Stopped short, other than on the caller's wall-time budget: say where, and why.
3496
+ let stopped;
3497
+ const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3498
+ 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.`;
3499
+ 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.`;
3500
+ return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3458
3501
  }
3459
3502
 
3460
- // Quadratic (or linear) extrapolation of the state to time t, from the current state and the
3461
- // two most recent history entries: a starting guess for Newton.
3462
- _extrapolate(out, t) {
3503
+ // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
3504
+ // the two most recent history entries: a starting guess for Newton. (In offsets built from the
3505
+ // steps' own lengths, never differences of absolute times: a second into a run, after a jump
3506
+ // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
3507
+ _extrapolate(out, h) {
3463
3508
  const [e0, e1] = this.history;
3464
- const t0 = this.time, u = this.u;
3509
+ const u = this.u;
3465
3510
  if (!e0) return undefined;
3466
3511
  if (!e1) {
3467
- const l = (t - t0) / (t0 - e0.t);
3512
+ const l = h / e0.dt;
3468
3513
  for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
3469
3514
  return out;
3470
3515
  }
3471
- const t1 = e0.t, t2 = e1.t;
3472
- const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
3473
- const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
3474
- const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
3516
+ const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
3475
3517
  for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
3476
3518
  return out;
3477
3519
  }
@@ -3480,7 +3522,7 @@ export class Solver {
3480
3522
  // linear after backward Euler), and the factor turning |u − pred| into the local error.
3481
3523
  _predict(pred, bdf) {
3482
3524
  const [e0, e1, e2] = this.history;
3483
- const t = this.time, h = e0.dt;
3525
+ const h = e0.dt;
3484
3526
  if (!e1) {
3485
3527
  pred.set(e0.u);
3486
3528
  return { scale: 1, order: 1 };
@@ -3488,18 +3530,15 @@ export class Solver {
3488
3530
  const hp = e1.dt;
3489
3531
  if (bdf && e2) {
3490
3532
  const hpp = e2.dt;
3491
- // Lagrange through (t0, u0), (t1, u1), (t2, u2) at t.
3492
- const t0 = e0.t, t1 = e1.t, t2 = e2.t;
3493
- const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
3494
- const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
3495
- const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
3533
+ // Lagrange through the three states before at the new time (offsets as in _extrapolate).
3534
+ const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
3496
3535
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
3497
3536
  const w = h / hp;
3498
3537
  const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
3499
3538
  const Cp = h * (h + hp) * (h + hp + hpp);
3500
3539
  return { scale: Cc / (Cc + Cp), order: 2 };
3501
3540
  }
3502
- const l0 = (t - e1.t) / (e0.t - e1.t), l1 = 1 - l0;
3541
+ const l0 = (h + hp) / hp, l1 = 1 - l0;
3503
3542
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
3504
3543
  // Backward Euler's error against a linear predictor; after a BDF2 step without enough
3505
3544
  // history this overestimates, which is safe.
@@ -3720,7 +3759,15 @@ export class Solver {
3720
3759
  for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
3721
3760
  const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
3722
3761
  const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
3723
- const cuts = this._currentCuts();
3762
+ // Where the measured contact's current may be read across a segment: anywhere without
3763
+ // ports; with them, only between it and the nearest window (a port's exchange takes current
3764
+ // in or out along it). Never for a port's own terminal.
3765
+ let cuts = kT < 2 ? this._currentCuts() : null;
3766
+ if (cuts && this.model.ports.length > 0) {
3767
+ const windows = this.model.ports.flatMap((p) => Array.from(p.nodes));
3768
+ const lo = Math.min(...windows), hi = Math.max(...windows);
3769
+ cuts = cuts.filter((cut) => (kT === 0 ? cut.s + 1 <= lo : cut.s >= hi));
3770
+ }
3724
3771
  // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
3725
3772
  // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
3726
3773
  // flux to round-off. So the dilute kernels' terms are kept in difference form (see
@@ -3889,7 +3936,7 @@ export class Solver {
3889
3936
  Ii += im;
3890
3937
  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);
3891
3938
  }
3892
- if (!cuts || kT >= 2) return { Ir, Ii, err };
3939
+ if (!cuts) return { Ir, Ii, err };
3893
3940
  const sgn = kT === 0 ? 1 : -1;
3894
3941
  for (const cut of cuts) {
3895
3942
  let re = 0, im = 0, e = 0;
@@ -3929,7 +3976,8 @@ export class Solver {
3929
3976
  // taken at its edges; and what's left, small, solved for to GMRES's floor, its error now
3930
3977
  // relative to itself. An electrolyte at its open circuit needs it: the response to its
3931
3978
  // terminal is nearly such a shift, and its current the slope of levels uniform to 1e-14,
3932
- // more than a double can hold. (Not with ports, whose exchange sees the shift.)
3979
+ // more than a double can hold. (A port's exchange sees the shift: its window's rows are
3980
+ // kept with the edges'.) So does a MOS capacitor's back contact, its channel held by a port.
3933
3981
  if (K === 0 && cuts && err > 1e-2 * Math.hypot(Ir, Ii)) {
3934
3982
  const [er, ei] = this._regionShift(yr, yi);
3935
3983
  const tr = new Float64Array(Nc), ti = new Float64Array(Nc);
@@ -4019,8 +4067,9 @@ export class Solver {
4019
4067
  return [er, ei];
4020
4068
  }
4021
4069
 
4022
- // Compact rows a region's uniform shift can touch: faces, the nodes beside them, and the two
4023
- // ends (whatever holds a level from outside). Inside a region, every row is unchanged by it.
4070
+ // Compact rows a region's uniform shift can touch: faces, the nodes beside them, the two ends
4071
+ // and the ports' windows (whatever holds a level from outside). Elsewhere inside a region,
4072
+ // every row is unchanged by it.
4024
4073
  _regionEdgeRows() {
4025
4074
  const { M, loc, nB } = this, R = this.rix;
4026
4075
  const edge = new Uint8Array(this.sys.size);
@@ -4036,16 +4085,17 @@ export class Solver {
4036
4085
  mark(b);
4037
4086
  mark(b + 1);
4038
4087
  }
4088
+ for (const port of this.model.ports) for (const g of port.nodes) mark(this.blockOfNode[g]);
4039
4089
  return edge;
4040
4090
  }
4041
4091
 
4042
- // The cuts where the impedance may read a small-signal current (null where the current
4043
- // differs between cuts: a device with ports). Each is the linearised total current toward +x
4044
- // through one segment of an ideal region, Σ (g + iω d)·δu over the unknowns idx: the
4045
- // Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the displacement's.
4092
+ // The cuts where the impedance may read a small-signal current. Each is the linearised total
4093
+ // current toward +x through one segment s of an ideal region, Σ (g + iω d)·δu over the
4094
+ // unknowns idx: the Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the
4095
+ // displacement's. Without ports it's the same through every cut; with them, only between a
4096
+ // contact and the nearest port's window (see _impedance).
4046
4097
  _currentCuts() {
4047
4098
  const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
4048
- if (model.ports.length > 0) return null;
4049
4099
  const { grid, materials, regions } = model;
4050
4100
  const cuts = [];
4051
4101
  for (let r = 0; r < regions.length; r++) {
@@ -4085,12 +4135,78 @@ export class Solver {
4085
4135
  }
4086
4136
  if (terms.size === 0) continue;
4087
4137
  const idx = [...terms.keys()];
4088
- cuts.push({ idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
4138
+ cuts.push({ s, idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
4089
4139
  }
4090
4140
  }
4091
4141
  return cuts;
4092
4142
  }
4093
4143
 
4144
+ // Each species' flux toward +x across every segment, at the current state (concentrations
4145
+ // computed): totals through the cross-section, mol/s (mol/(m²·s) for a planar device). A face's
4146
+ // segment, between its pair of nodes, carries the face's flux; a conductor's, its carrier's.
4147
+ // These are the fluxes the balances add up (displacement aside), from the same formulas.
4148
+ segmentFluxes() {
4149
+ const { model, n, M, u, uLo, c, z, ex } = this;
4150
+ const { grid, materials, regions, interfaces } = model;
4151
+ const out = Array.from({ length: n }, () => new Float64Array(grid.nNodes - 1));
4152
+ for (let r = 0; r < regions.length; r++) {
4153
+ const reg = regions[r], mat = materials[reg.material], vel = reg.velocity;
4154
+ for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
4155
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
4156
+ if (mat.conductor) {
4157
+ out[mat.conductor.i][s] = u[bL * M];
4158
+ continue;
4159
+ }
4160
+ for (let i = 0; i < n; i++) {
4161
+ if (!mat.present[i] || mat.D[i] === 0) continue;
4162
+ const pe = (vel * h) / mat.D[i];
4163
+ const d = z[i] * (u[bR * M] - u[bL * M]) + ex[(s + 1) * n + i] - ex[s * n + i] - pe;
4164
+ const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]) - pe;
4165
+ out[i][s] = -(mat.D[i] / h) * bernoulli(d) * c[s * n + i] * Math.expm1(deta);
4166
+ }
4167
+ if (reg.mixing > 0) {
4168
+ // Eddy mixing (see _segmentMixing): −(D_mix/h) c̄_i (Δη_i − z_i q), c̄ the logarithmic mean.
4169
+ const on = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0);
4170
+ const cb = [], de = [];
4171
+ let S = 0, Q = 0;
4172
+ for (const k of on) {
4173
+ const a = c[s * n + k], b = c[(s + 1) * n + k];
4174
+ cb[k] = a === b ? a : (b - a) / Math.log(b / a);
4175
+ de[k] = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]);
4176
+ S += z[k] * z[k] * cb[k];
4177
+ Q += z[k] * cb[k] * de[k];
4178
+ }
4179
+ const q = S > 0 ? Q / S : 0;
4180
+ for (const i of on) out[i][s] -= (reg.mixing / h) * cb[i] * (de[i] - z[i] * q);
4181
+ }
4182
+ }
4183
+ }
4184
+ interfaces.forEach((itf, f) => {
4185
+ const gL = grid.regionEnd[f], b = this.blockOfFace[f];
4186
+ for (let i = 0; i < n; i++) if (itf.links[i].type !== 'blocked') out[i][gL] = grid.area[gL] * u[b * M + 1 + i];
4187
+ });
4188
+ return out;
4189
+ }
4190
+
4191
+ // A face's one-way fluxes for each species crossing it by permeability, per area: toward +x
4192
+ // and toward −x, whose difference is its net flux (Ussing's unidirectional fluxes; GHK's two
4193
+ // terms between like solutions). null for a species crossing any other way.
4194
+ faceOneWay(f) {
4195
+ const { model, n, M, u, uLo, z } = this, itf = model.interfaces[f], grid = model.grid;
4196
+ const bL = this.blockOfFace[f] - 1, bR = bL + 2, gl = grid.regionEnd[f], gr = gl + 1;
4197
+ return itf.links.map((link, i) => {
4198
+ if (link.type !== 'permeability') return null;
4199
+ const kl = gl * n + i, kr = gr * n + i;
4200
+ const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
4201
+ const d = z[i] * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
4202
+ const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]);
4203
+ const gated = link.gates ?? [];
4204
+ const P = link.P * (gated.length ? this._open(gated, (q) => u[this._gateSlot(f, q)]) : 1);
4205
+ const right = P * bernoulli(d) * this.c[kl];
4206
+ return [right, right * Math.exp(deta)];
4207
+ });
4208
+ }
4209
+
4094
4210
  // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
4095
4211
  _smallSignalProfiles(xr, xi) {
4096
4212
  const { n, M, z, VT, model } = this;
@@ -4264,7 +4380,7 @@ export class Solver {
4264
4380
  // pseudo-transient ramp: one direct attempt first.
4265
4381
  const quick = (canContinue || this.hasGeneration) && direct && opts.continuation !== false;
4266
4382
  let r = this._solveSteady(quick ? { ...opts, maxSteps: 1 } : opts);
4267
- if (r.converged) this.solvedV = target;
4383
+ if (r.converged) this.solvedV = [level, target];
4268
4384
  if (r.converged) return r;
4269
4385
  // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
4270
4386
  // nothing, each solve warm from the last.
@@ -4278,7 +4394,7 @@ export class Solver {
4278
4394
  const g = this._generationContinuation(opts, r);
4279
4395
  dimmer = this.dimmer;
4280
4396
  if (g.converged) {
4281
- this.solvedV = target;
4397
+ this.solvedV = [level, target];
4282
4398
  return g;
4283
4399
  }
4284
4400
  r = g;
@@ -4319,17 +4435,23 @@ export class Solver {
4319
4435
  this.computeConcentrations();
4320
4436
  };
4321
4437
  restart();
4322
- // Ramp from the last converged voltage when the state is that solution (a warm start),
4323
- // otherwise from level terminals.
4324
- const from = this.solvedV !== undefined ? this.solvedV : level;
4325
- const c = this._continuation(opts, from, target, r, from !== level);
4438
+ // Ramp from the contacts' voltages at the last converged solve when the state is that
4439
+ // solution (a warm start): the left one first where it moved, the right held where it was,
4440
+ // then the right. Otherwise from level terminals.
4441
+ const solved = this.solvedV;
4442
+ let c;
4443
+ if (solved) {
4444
+ c = solved[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, solved[0], level, r, true, solved[1]);
4445
+ // (Ramping the right from level terminals, solve there first: a sweep's start.)
4446
+ if (c.converged) c = this._continuation(opts, 1, solved[1], target, c, c.ramped || solved[1] !== level);
4447
+ } else c = this._continuation(opts, 1, level, target, r, false);
4326
4448
  if (c.converged) {
4327
- this.solvedV = target;
4449
+ this.solvedV = [level, target];
4328
4450
  return c;
4329
4451
  }
4330
4452
  restart();
4331
4453
  r = this._solveSteady(opts); // the full pseudo-transient ramp
4332
- if (r.converged) this.solvedV = target;
4454
+ if (r.converged) this.solvedV = [level, target];
4333
4455
  return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
4334
4456
  }
4335
4457
 
@@ -4410,13 +4532,15 @@ export class Solver {
4410
4532
  // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
4411
4533
  // limit: its bordered row 33 digits short), but a current-driven steady state is the held
4412
4534
  // one at the voltage that passes the target: found by held solves, by regula falsi
4413
- // (Illinois) within the bracket, to 1e-10 of the target.
4535
+ // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
4536
+ // current the march saw (a target of 0 is never met exactly).
4537
+ const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
4414
4538
  try {
4415
4539
  let side = 0;
4416
4540
  for (let it = 0; it < 60; it++) {
4417
4541
  const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
4418
4542
  if (!Number.isFinite(fm)) return fail();
4419
- if (Math.abs(fm) <= 1e-10 * Math.abs(target)) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4543
+ if (Math.abs(fm) <= 1e-10 * scale) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4420
4544
  if (Math.sign(fm) === Math.sign(fb)) {
4421
4545
  [Vb, fb] = [Vm, fm];
4422
4546
  if (side === -1) fa /= 2;
@@ -4474,12 +4598,18 @@ export class Solver {
4474
4598
  }
4475
4599
  }
4476
4600
 
4477
- _continuation(opts, level, target, r, warm) {
4601
+ // Terminal k (a contact) ramped from `level` to `target` through steady solves, each warm from
4602
+ // the last (`hold`: the other contact's voltage meanwhile, if not its own). A warm ramp that has
4603
+ // nowhere to go converges only if what it started from did (`ramped`): the state it carries
4604
+ // must be some ramp's solution, never just the one a failed solve left.
4605
+ _continuation(opts, k, level, target, r, warm, hold) {
4478
4606
  const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
4479
4607
  let V = level, dV = (target - level) / 8, steps = r.steps, iterations = r.iterations;
4480
4608
  const history = r.history.slice();
4609
+ if (warm && V === target) return { ...r, converged: r.ramped === true, steps, iterations, history, ramped: r.ramped };
4481
4610
  try {
4482
- this.sourceOverride.set(1, V);
4611
+ this.sourceOverride.set(k, V);
4612
+ if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4483
4613
  let q;
4484
4614
  if (!warm) {
4485
4615
  q = this._solveSteady(opts);
@@ -4501,7 +4631,7 @@ export class Solver {
4501
4631
  while (V !== target) {
4502
4632
  const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4503
4633
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4504
- this.sourceOverride.set(1, next);
4634
+ this.sourceOverride.set(k, next);
4505
4635
  q = this._solveSteady(sub);
4506
4636
  steps += q.steps;
4507
4637
  iterations += q.iterations;
@@ -4518,9 +4648,10 @@ export class Solver {
4518
4648
  if (Math.abs(dV) < 1e-6 * Math.abs(target - level)) return { converged: false, steps, iterations, history };
4519
4649
  }
4520
4650
  }
4521
- return { converged: true, steps, iterations, history };
4651
+ return { converged: true, steps, iterations, history, ramped: true };
4522
4652
  } finally {
4523
- this.sourceOverride.delete(1);
4653
+ this.sourceOverride.delete(k);
4654
+ if (hold !== undefined) this.sourceOverride.delete(1 - k);
4524
4655
  this._refreshSources();
4525
4656
  }
4526
4657
  }
package/src/stack.js CHANGED
@@ -87,16 +87,18 @@ export function ohmic(d = 0, carriers = ['e-', 'h+']) {
87
87
  }
88
88
 
89
89
  /**
90
- * A bath: a neutral composition `c` (mol/m³) held at the contact, its levels referred to the
91
- * charged `reference` species (the ion a reference electrode would sense, e.g. Cl⁻ for
92
- * Ag/AgCl), which is the terminal.
90
+ * A bath: a neutral composition `c` (mol/m³) held at the contact. With a `reference`, the charged
91
+ * species a reference electrode would sense (Cl⁻ for Ag/AgCl), its levels are referred to that
92
+ * species, which is the terminal. Without one, `bath(c, drive)`, the terminal voltage is the
93
+ * bath's φ: an ideal salt bridge, the convention of membrane potentials.
93
94
  * @param {Record<string, number>} c
94
- * @param {string} reference
95
+ * @param {string | number | object} [reference] the reference species, or the drive
95
96
  * @param {number | object} [drive]
96
97
  * @param {{ offset?: number }} [opts] where the reference species sits relative to the terminal
97
98
  * voltage, V_ref = V + offset (V)
98
99
  */
99
100
  export function bath(c, reference, d = 0, { offset } = {}) {
101
+ if (typeof reference !== 'string') return { ...drive(reference ?? 0, 'bath'), bath: { c: { ...c } } };
100
102
  return { ...drive(d, 'bath'), bath: { c: { ...c }, reference, ...(offset === undefined ? {} : { offset }) } };
101
103
  }
102
104
 
package/src/types.js CHANGED
@@ -114,7 +114,8 @@
114
114
  * @typedef {'blocked' | 'equilibrium'
115
115
  * | { type: 'equilibrium', offset?: number, mu?: number }
116
116
  * | { type: 'conductance', G: number, offset?: number }
117
- * | { type: 'exchange', k: number, mu: number }} ContactSpeciesLink
117
+ * | { type: 'exchange', k: number, mu: number }
118
+ * | { type: 'velocity', v: number, offset?: number, mu?: number }} ContactSpeciesLink
118
119
  */
119
120
  /**
120
121
  * 'bulk': the end node is plain bulk (locally neutral, no double layer); 'neutral': no charge at
@@ -1,6 +1,6 @@
1
1
  export function ghkCurrent(z: any, inside: any, outside: any, V: any, T: any): number;
2
2
  /**
3
- * Hodgkin and Huxley's squid-axon channels, for a membrane face with the outside on its left
3
+ * Hodgkin and Huxley's squid-axon channels: for a membrane face with the outside on its left
4
4
  * (so the gates' voltage, φ_right − φ_left, is the membrane potential): the face's `gates`, and
5
5
  * `species` links for Na⁺ (P·m³h), K⁺ (P·n⁴) and the leak (ungated), to spread into the face
6
6
  * beside its φ law, `{ phi: { type: 'capacitive', C: 0.01 }, ...hh }`, adding links (or blocks)
@@ -12,12 +12,21 @@ export function ghkCurrent(z: any, inside: any, outside: any, V: any, T: any): n
12
12
  * (`leak`, of charge `leakZ`: Cl⁻ by default, whose Nernst level in squid is near their leak
13
13
  * reversal, −54 mV). The
14
14
  * rates are theirs at 6.3 °C, scaled to `T` by Q₁₀ = 3.
15
+ *
16
+ * Or with `linear: true`, Hodgkin and Huxley's own linear channels on the face: conductance links,
17
+ * G = g, gated the same, whose currents are g(V − E) with E each ion's Nernst level at the
18
+ * concentrations on either side (so `inside`, `outside` and `at` aren't needed).
19
+ *
20
+ * Or, given `area` (membrane per volume of the region, 2/a for an axon of radius a), for a membrane
21
+ * port along a region: links that are their linear conductances, G = g × area per volume, gated the
22
+ * same, each ion's reversal potential following from the port's bath and the concentrations inside
23
+ * (so `inside`, `outside` and `at` aren't needed).
15
24
  * @param {{ inside: object, outside: object, T?: number, g?: { Na?: number, K?: number, leak?: number },
16
- * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string } }} opts concentrations in
25
+ * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string }, linear?: boolean, area?: number }} opts concentrations in
17
26
  * mol/m³ by species name, K, S/m², V
18
27
  * @returns {{ gates: object, species: object }}
19
28
  */
20
- export function hodgkinHuxley({ inside, outside, T, g, leak, leakZ, at, names }?: {
29
+ export function hodgkinHuxley({ inside, outside, T, g, leak, leakZ, at, names, linear, area }?: {
21
30
  inside: object;
22
31
  outside: object;
23
32
  T?: number;
@@ -33,6 +42,8 @@ export function hodgkinHuxley({ inside, outside, T, g, leak, leakZ, at, names }?
33
42
  Na?: string;
34
43
  K?: string;
35
44
  };
45
+ linear?: boolean;
46
+ area?: number;
36
47
  }): {
37
48
  gates: object;
38
49
  species: object;
package/types/checks.d.ts CHANGED
@@ -18,7 +18,8 @@
18
18
  * zero; under light it's J = F(G − R), and it says where every carrier went.
19
19
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
20
20
  * what came in.
21
- * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
21
+ * - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
22
+ * answer depends on the whole run): in a steady state, the device solved again on a grid
22
23
  * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
23
24
  * region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
24
25
  * wants less). The discretisation is second order, so the change estimates this grid's error
package/types/kit.d.ts CHANGED
@@ -36,6 +36,7 @@ export function vacuumZeroCharge(def: object, workFunction: number, inside: Vacu
36
36
  export { live } from "./live.js";
37
37
  export { recorder } from "./recorder.js";
38
38
  export { check } from "./checks.js";
39
+ export { particles } from "./particles.js";
39
40
  export { polarization } from "./polarization.js";
40
41
  /**
41
42
  * One material seen from vacuum: a level inside it (the anchor) and how far above that level,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * A swarm of dots sampling a solution's concentrations and fluxes. Amounts are per area (a planar
3
+ * device's). `step(dt)` moves them on in the device's time, at the rates of the solution last
4
+ * given; `update(sol)` gives a new one.
5
+ * @param {object} sol a solution (from solve(), advance(), live(), …)
6
+ * @param {object} [opts]
7
+ * @param {string[]} [opts.species] which species (default: every species with a concentration)
8
+ * @param {number} [opts.dots] about how many dots per species (default 200), for the default weight
9
+ * @param {number|object} [opts.weight] amount per dot, mol/m²: one number for every species (so
10
+ * their dots compare), or { name: weight }; default, each species' total over `dots`
11
+ * @param {number} [opts.cap] the most dots a cell holds on average before it's a sea (default ∞)
12
+ * @param {number} [opts.cells] about how many cells across the device (default 120)
13
+ * @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
14
+ */
15
+ export function particles(sol: object, { species, dots, weight, cap, cells, random }?: {
16
+ species?: string[];
17
+ dots?: number;
18
+ weight?: number | object;
19
+ cap?: number;
20
+ cells?: number;
21
+ random?: () => number;
22
+ }): {
23
+ cells: {
24
+ x0: any;
25
+ x1: any;
26
+ region: any;
27
+ }[];
28
+ species: any;
29
+ weight: {};
30
+ sea: {};
31
+ dots: any[];
32
+ events: any[];
33
+ crossed: {};
34
+ time: number;
35
+ update: (next: any, { resample }?: {
36
+ resample?: boolean;
37
+ }) => /*elided*/ any;
38
+ step: (dt: any, { maxHops }?: {
39
+ maxHops?: number;
40
+ }) => /*elided*/ any;
41
+ expected: (nm: any) => Float64Array<ArrayBuffer>;
42
+ };
@@ -9,6 +9,7 @@ export function makeSolution(solver: any, result?: {}): {
9
9
  muStd: {};
10
10
  V: {};
11
11
  Vstd: {};
12
+ T: any;
12
13
  time: any;
13
14
  steady: any;
14
15
  converged: any;
package/types/solver.d.ts CHANGED
@@ -209,9 +209,11 @@ export class Solver {
209
209
  mixWork: Float64Array<any>[];
210
210
  _dc(g: any, k: any, d: any): void;
211
211
  bulkRate(rx: any, g: any): number;
212
+ bulkForward(rx: any, g: any): any;
212
213
  bulkOneWay(rx: any, g: any): number;
213
214
  _srhRate(law: any, cn: any, cp: any, a: any): {
214
215
  rate: number;
216
+ forward: number;
215
217
  oneWay: number;
216
218
  Cn: number;
217
219
  Cp: number;
@@ -294,6 +296,7 @@ export class Solver {
294
296
  * animation can call it once per frame; the step size carries over between calls.
295
297
  */
296
298
  integrate(tEnd: any, opts?: {}): {
299
+ stopped?: string;
297
300
  converged: boolean;
298
301
  done: boolean;
299
302
  steps: number;
@@ -307,7 +310,7 @@ export class Solver {
307
310
  };
308
311
  };
309
312
  landing: any;
310
- _extrapolate(out: any, t: any): any;
313
+ _extrapolate(out: any, h: any): any;
311
314
  _predict(pred: any, bdf: any): {
312
315
  scale: number;
313
316
  order: number;
@@ -381,10 +384,13 @@ export class Solver {
381
384
  _regionShift(yr: any, yi: any): Float64Array<any>[];
382
385
  _regionEdgeRows(): Uint8Array<any>;
383
386
  _currentCuts(): {
387
+ s: any;
384
388
  idx: any[];
385
389
  g: any[];
386
390
  d: any[];
387
391
  }[];
392
+ segmentFluxes(): Float64Array<ArrayBuffer>[];
393
+ faceOneWay(f: any): any;
388
394
  _smallSignalProfiles(xr: any, xi: any): {
389
395
  phi: {
390
396
  re: Float64Array<any>;
@@ -424,7 +430,7 @@ export class Solver {
424
430
  iterations: any;
425
431
  history: any;
426
432
  };
427
- _continuation(opts: any, level: any, target: any, r: any, warm: any): any;
433
+ _continuation(opts: any, k: any, level: any, target: any, r: any, warm: any, hold: any): any;
428
434
  _solveSteady(opts?: {}): {
429
435
  converged: boolean;
430
436
  steps: number;