driftlet 0.4.0 → 0.6.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.
@@ -0,0 +1,89 @@
1
+ // A transient recorder, for driftlet/kit: advance() in pieces (a run to completion, or a little
2
+ // each animation frame), with the trace joined up across calls and whole solutions kept as
3
+ // frames, for scrubbing back through a run or plotting snapshots side by side.
4
+
5
+ import { DeviceError } from './errors.js';
6
+
7
+ const fail = (message) => {
8
+ throw new DeviceError(message);
9
+ };
10
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
11
+
12
+ /**
13
+ * Record a device's transient. Frames are whole solutions: at the start, then at each of
14
+ * `times` (or every `every` seconds from the start), which `advance()` lands on exactly; with
15
+ * neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
16
+ * `probes` if given) runs from the start through every accepted step of every call.
17
+ * @param {import('./index.js').Device} device
18
+ * @param {{ every?: number, times?: number[], probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
19
+ */
20
+ export function recorder(device, opts = {}) {
21
+ const { every, times, probes } = opts;
22
+ if (every !== undefined && times !== undefined) fail('recorder: give every or times, not both');
23
+ if (every !== undefined && !(every > 0 && Number.isFinite(every))) fail('recorder: every must be a time > 0 (s)');
24
+ if (times !== undefined && !(Array.isArray(times) && times.every(Number.isFinite))) fail('recorder: times must be an array of times (s)');
25
+ const first = device.solution();
26
+ const t0 = first.time;
27
+ const schedule = times ? [...times].filter((t) => t > t0).sort((a, b) => a - b) : null;
28
+ const scheduled = schedule !== null || every !== undefined;
29
+ let next = every !== undefined ? 1 : 0; // the next frame due: schedule[next], or t0 + next·every
30
+ const due = () => (schedule ? schedule[next] : every !== undefined ? t0 + next * every : undefined);
31
+
32
+ const trace = { t: [first.time], current: [first.current], voltage: [first.terminals.right.V - first.terminals.left.V] };
33
+ if (probes) trace.probes = device.solver._probes(probes).read().map((v) => [v]); // the start's readings
34
+ const frames = [first];
35
+
36
+ /**
37
+ * Advance to tEnd (s), stopping at each frame time on the way to keep a frame. Takes
38
+ * advance()'s options; with `budgetMs` it returns when that much wall time is spent, `done`
39
+ * saying whether tEnd was reached. Returns the latest solution (its own `trace` is only the
40
+ * last piece: the recorder's `trace` is the whole run).
41
+ * @param {number} tEnd
42
+ * @param {object} [aopts] as for Device.advance()
43
+ */
44
+ function advance(tEnd, aopts = {}) {
45
+ const start = now(), budget = aopts.budgetMs ?? Infinity;
46
+ let sol;
47
+ for (;;) {
48
+ const stop = due();
49
+ const target = stop !== undefined && stop < tEnd ? stop : tEnd;
50
+ const left = budget - (now() - start);
51
+ sol = device.advance(target, { ...aopts, ...(probes ? { probes } : {}), budgetMs: left > 0 ? left : 0 });
52
+ const tr = sol.trace;
53
+ for (let k = 0; k < tr.t.length; k++) {
54
+ trace.t.push(tr.t[k]);
55
+ trace.current.push(tr.current[k]);
56
+ trace.voltage.push(tr.voltage[k]);
57
+ if (probes) tr.probes.forEach((p, j) => trace.probes[j].push(p[k]));
58
+ }
59
+ if (!sol.converged || !sol.done) break;
60
+ if (target === stop) {
61
+ frames.push(sol);
62
+ next++;
63
+ }
64
+ if (target === tEnd) break;
65
+ if (now() - start > budget) {
66
+ sol.done = false;
67
+ break;
68
+ }
69
+ }
70
+ if (!scheduled && sol.converged && sol.time > frames.at(-1).time) frames.push(sol);
71
+ return sol;
72
+ }
73
+
74
+ /**
75
+ * The frame at or just before time t (the first frame before the start).
76
+ * @param {number} t
77
+ */
78
+ function frame(t) {
79
+ let lo = 0, hi = frames.length - 1;
80
+ while (lo < hi) {
81
+ const mid = (lo + hi + 1) >> 1;
82
+ if (frames[mid].time <= t) lo = mid;
83
+ else hi = mid - 1;
84
+ }
85
+ return frames[lo];
86
+ }
87
+
88
+ return { advance, frame, frames, trace };
89
+ }
package/src/solution.js CHANGED
@@ -27,6 +27,7 @@ export function makeSolution(solver, result = {}) {
27
27
  V: {},
28
28
  Vstd: {},
29
29
  time: solver.time,
30
+ steady: solver.atSteady,
30
31
  converged: result.converged ?? true,
31
32
  iterations: result.iterations ?? 0,
32
33
  steps: result.steps,
@@ -121,7 +122,24 @@ export function makeSolution(solver, result = {}) {
121
122
  const N = {};
122
123
  for (let i = 0; i < n; i++) N[species[i].name] = u[b * M + 1 + i];
123
124
  const rates = itf.reactions.map((_, k) => u[b * M + 1 + n + k]); // mol/(m²·s), forward
124
- return { dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
125
+ return { left: model.regions[f].name, right: model.regions[f + 1].name, dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
126
+ });
127
+
128
+ // Bulk reactions: each one's forward rate at every node (mol/(m³·s), NaN where it doesn't run),
129
+ // and integrated over each region and the device (mol/(m²·s)), by the nodes' control volumes,
130
+ // just as the balances count it.
131
+ sol.bulkReactions = solver.rxs.map((rx) => {
132
+ const rate = new Float64Array(nNodes).fill(NaN), regions = model.regions.map(() => 0);
133
+ let total = 0;
134
+ for (let g = 0; g < nNodes; g++) {
135
+ const m = solver.nodeMaterial[g];
136
+ if (!(rx.kf[m] > 0)) continue;
137
+ rate[g] = solver.bulkRate(rx, g);
138
+ const amount = grid.vol[g] * rate[g];
139
+ regions[grid.nodeRegion[g]] += amount;
140
+ total += amount;
141
+ }
142
+ return { rate, regions, total };
125
143
  });
126
144
 
127
145
  // Total charge per area in the device (space charge plus sheet charges).
package/src/solver.js CHANGED
@@ -321,6 +321,7 @@ export class Solver {
321
321
  this.jR = new Float64Array(M);
322
322
  this.time = 0;
323
323
  this.lastDt = Infinity;
324
+ this.atSteady = false; // whether the state is a converged steady solve
324
325
  // Contact bookkeeping, filled by assemble(): particle flux toward +x through each contact,
325
326
  // and the displacement there (the metal's surface charge for a neutral link).
326
327
  this.contactFlux = { left: new Float64Array(n), right: new Float64Array(n) };
@@ -362,6 +363,7 @@ export class Solver {
362
363
  const { x: gx, segLength } = model.grid;
363
364
  this.rxs = model.reactions.map((rx) => ({
364
365
  kf: rx.kf,
366
+ srh: rx.srh, // per material: SRH kinetics in place of mass action, or null
365
367
  kfNode: Float64Array.from(gx, (x, g) => {
366
368
  const m = this.nodeMaterial[g], p = rx.kfProfile[m];
367
369
  return p ? profileMean(p, x - (g > 0 ? segLength[g - 1] : 0) / 2, x + (g < segLength.length ? segLength[g] : 0) / 2) : rx.kf[m];
@@ -563,9 +565,63 @@ export class Solver {
563
565
  // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
564
566
  add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch);
565
567
  }
568
+ // A combination of immobile stretches (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻) is conserved
569
+ // node by node, since nothing carries it anywhere: at each node its weighted sum stays what
570
+ // it was, a row local to that node's block, in place of the balance row of one of them.
571
+ this.localConstraints = [];
572
+ const immobile = (st) => {
573
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) if (materials[regions[q].material].D[st.species] > 0) return false;
574
+ return true;
575
+ };
576
+ for (const w of this.moieties) {
577
+ const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
578
+ const sts = parts.map((p) => this.stretches[p.stretch]);
579
+ if (sts.some((st) => st.conserved || !immobile(st) || conductor(st))) continue;
580
+ if (sts.some((st) => st.nodes[0] !== sts[0].nodes[0] || st.nodes[1] !== sts[0].nodes[1])) continue; // (side by side only)
581
+ for (const st of sts) st.conserved = true;
582
+ const row = parts.find((p) => p.w === 1) ?? parts[0];
583
+ this.localConstraints.push({
584
+ parts: parts.map((p) => ({ i: this.stretches[p.stretch].species, w: p.w })),
585
+ row: 1 + this.stretches[row.stretch].species,
586
+ nodes: sts[0].nodes,
587
+ reference: new Float64Array(sts[0].nodes[1] - sts[0].nodes[0] + 1),
588
+ });
589
+ }
566
590
  this.constrained = false;
567
591
  }
568
592
 
593
+ // Each local constraint's weighted sum at each node, as the state holds it now: what a steady
594
+ // solve keeps.
595
+ _captureLocal() {
596
+ const { n, c } = this;
597
+ for (const lc of this.localConstraints) {
598
+ for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
599
+ let t = 0;
600
+ for (const { i, w } of lc.parts) t += w * c[g * n + i];
601
+ lc.reference[g - lc.nodes[0]] = t;
602
+ }
603
+ }
604
+ }
605
+
606
+ // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
607
+ _applyLocalConstraints() {
608
+ const { n, M, c, res, dA: d } = this;
609
+ for (const lc of this.localConstraints) {
610
+ for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
611
+ const b = this.blockOfNode[g];
612
+ if (this.loc[b * M + lc.row] < 0) continue;
613
+ this._replaceRow(b, lc.row);
614
+ let t = 0;
615
+ for (const { i, w } of lc.parts) {
616
+ t += w * c[g * n + i];
617
+ this._dc(g, i, d);
618
+ for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
619
+ }
620
+ res[this.rix[b * M + lc.row]] = t - lc.reference[g - lc.nodes[0]];
621
+ }
622
+ }
623
+ }
624
+
569
625
  // A basis of the conserved combinations of stretch amounts: the null space of the
570
626
  // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
571
627
  // for each stretch fed from outside. Exact integer data, so plain elimination will do.
@@ -747,6 +803,7 @@ export class Solver {
747
803
  // After the terminals' drives change (Device.set): which are floating, and the held values. A
748
804
  // change of source is a discontinuity, so time stepping restarts its order.
749
805
  redrive() {
806
+ this.atSteady = false;
750
807
  const before = this.floating;
751
808
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
752
809
  if (this.floating.length !== before.length) this.deltaV = null;
@@ -1115,6 +1172,7 @@ export class Solver {
1115
1172
  // flux), then contacts, then the circuit rows of the floating ones.
1116
1173
  this._terminals(dt);
1117
1174
  if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1175
+ if (this.constrained && dt === Infinity && this.localConstraints.length > 0) this._applyLocalConstraints();
1118
1176
  this.transformed = this.combining && dt !== Infinity;
1119
1177
  if (this.transformed) this._chargeRows(dt);
1120
1178
  }
@@ -1304,6 +1362,26 @@ export class Solver {
1304
1362
  const dr = this.dA;
1305
1363
  for (let x = 0; x < rxs.length; x++) {
1306
1364
  const rx = rxs[x];
1365
+ const law = rx.srh?.[this.nodeMaterial[g]];
1366
+ if (law) {
1367
+ // SRH: dr is ∂rate/∂slot directly, from its sensitivities to ln n, ln p and a.
1368
+ let aHi = rx.fixedA, aLo = 0;
1369
+ for (let p = 0; p < rx.sp.length; p++) {
1370
+ aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
1371
+ aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
1372
+ }
1373
+ const { rate, Cn, Cp, Ca } = this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo);
1374
+ dr.fill(0);
1375
+ this._dlnc(g, law.n.i, Cn, dr);
1376
+ this._dlnc(g, law.p.i, Cp, dr);
1377
+ for (let p = 0; p < rx.sp.length; p++) dr[1 + rx.sp[p]] += Ca * rx.nu[p];
1378
+ for (let p = 0; p < rx.sp.length; p++) {
1379
+ const row = 1 + rx.sp[p], w = v * rx.nu[p];
1380
+ res[R[b * M + row]] += w * rate;
1381
+ for (let k = 0; k < M; k++) if (dr[k] !== 0) this._j(b, row, b, k, w * dr[k]);
1382
+ }
1383
+ continue;
1384
+ }
1307
1385
  let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g], aHi = rx.fixedA, aLo = 0;
1308
1386
  dr.fill(0); // ∂ ln P / ∂slot
1309
1387
  // Participants: reactants with ν > 0, then products with ν < 0.
@@ -1639,6 +1717,53 @@ export class Solver {
1639
1717
  d[0] = -this._Kz(g, k);
1640
1718
  }
1641
1719
 
1720
+ // A bulk reaction's rate at node g (mol/(m³·s), forward), as assembled: for the solution.
1721
+ bulkRate(rx, g) {
1722
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1723
+ let aHi = rx.fixedA, aLo = 0;
1724
+ for (let p = 0; p < rx.sp.length; p++) {
1725
+ aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
1726
+ aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
1727
+ }
1728
+ const law = rx.srh?.[this.nodeMaterial[g]];
1729
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo).rate;
1730
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
1731
+ 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]);
1732
+ return P * -Math.expm1(-(aHi + aLo));
1733
+ }
1734
+
1735
+ // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
1736
+ // rate is read against (in equilibrium the net rate is their round-off).
1737
+ bulkOneWay(rx, g) {
1738
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1739
+ let a = rx.fixedA;
1740
+ 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]]);
1741
+ const law = rx.srh?.[this.nodeMaterial[g]];
1742
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).oneWay;
1743
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
1744
+ 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]);
1745
+ return P * Math.max(1, Math.exp(-a));
1746
+ }
1747
+
1748
+ // SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
1749
+ // a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}).
1750
+ _srhRate(law, cn, cp, a) {
1751
+ const { tn, tp } = law, lnnp = Math.log(cn) + Math.log(cp);
1752
+ let n1, kN = 0, kP = 0, kA = 0; // ln n₁ = kN ln n + kP ln p + kA a + const
1753
+ if (Number.isNaN(law.n1)) {
1754
+ n1 = Math.exp((lnnp - a) / 2);
1755
+ kN = kP = 0.5;
1756
+ kA = -0.5;
1757
+ } else n1 = law.n1;
1758
+ const p1 = Math.exp(lnnp - a - Math.log(n1)), np = cn * cp;
1759
+ const den = tp * (cn + n1) + tn * (cp + p1), f = -Math.expm1(-a);
1760
+ const rate = (np * f) / den;
1761
+ const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
1762
+ const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
1763
+ const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
1764
+ 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 };
1765
+ }
1766
+
1642
1767
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
1643
1768
  // with the row r_k − rate(u_L, u_R) = 0, and each participant's edge node takes ν·r_k (made
1644
1769
  // there when ν > 0). The rate couples the two edge nodes only through the face block between
@@ -1650,24 +1775,34 @@ export class Solver {
1650
1775
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
1651
1776
  const al = rx.alpha;
1652
1777
  const dL = this.dA.fill(0), dR = this.dB.fill(0); // ∂ ln(prefactor)/∂slot, per side
1653
- let pref = rx.k0, aHi = rx.fixedA, aLo = 0;
1778
+ let pref = rx.k0, aHi = rx.fixedA, aLo = 0, rate;
1654
1779
  for (const p of rx.part) {
1655
1780
  const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
1656
1781
  aHi -= p.nu * u[o];
1657
1782
  aLo -= p.nu * uLo[o];
1658
- if (this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1783
+ if (rx.srh || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1659
1784
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1660
1785
  pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1661
1786
  this._dlnc(g, p.i, e, p.side ? dR : dL);
1662
1787
  }
1663
- const { g: bv, gp } = bvFactor(aHi + aLo, al);
1664
- const rate = pref * bv;
1665
- // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
1666
- for (let s = 0; s < M; s++) {
1667
- dL[s] *= rate;
1668
- dR[s] *= rate;
1788
+ if (rx.srh) {
1789
+ // SRH, with n and p each at its own side's edge node.
1790
+ const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
1791
+ const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo);
1792
+ rate = s.rate;
1793
+ this._dlnc(gn, pn.i, s.Cn, pn.side ? dR : dL);
1794
+ this._dlnc(gq, pp.i, s.Cp, pp.side ? dR : dL);
1795
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= s.Ca * p.nu;
1796
+ } else {
1797
+ const { g: bv, gp } = bvFactor(aHi + aLo, al);
1798
+ rate = pref * bv;
1799
+ // ∂rate/∂slot = rate·∂ln(prefactor) + pref·g′·∂a, with ∂a/∂η_i = −ν
1800
+ for (let s = 0; s < M; s++) {
1801
+ dL[s] *= rate;
1802
+ dR[s] *= rate;
1803
+ }
1804
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
1669
1805
  }
1670
- for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= pref * gp * p.nu;
1671
1806
  const slot = 1 + n + k, o = bf * M + slot;
1672
1807
  res[R[o]] = u[o] - rate;
1673
1808
  this._j(bf, slot, bf, slot, 1);
@@ -2066,6 +2201,7 @@ export class Solver {
2066
2201
  * the start-of-step values, and dt/a0 replaces dt.
2067
2202
  */
2068
2203
  step(dt, opts = {}) {
2204
+ this.atSteady = false;
2069
2205
  const prev = this.history[0];
2070
2206
  const w = prev ? dt / prev.dt : 0;
2071
2207
  const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
@@ -2148,6 +2284,9 @@ export class Solver {
2148
2284
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
2149
2285
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
2150
2286
  let grow = 0; // longer first steps tried after a Newton failure (see below)
2287
+ // The shortest step worth trying: round-off relative to the time, or the device's fastest
2288
+ // time scale, whichever is shorter (a cold start can need steps far below a long run's 1e-14).
2289
+ const floor = Math.min(1e-14 * Math.max(tEnd, 1e-300), this.fastestTime() * 1e-2);
2151
2290
  const pred = new Float64Array(this.u.length), guess = new Float64Array(this.u.length);
2152
2291
  const factor = (err, p) => (err > 0 ? Math.min(2, Math.max(0.2, 0.9 * Math.exp(Math.log(tol / err) / (p + 1)))) : 2);
2153
2292
  while (this.time < tEnd) {
@@ -2204,7 +2343,7 @@ export class Solver {
2204
2343
  grow = 3;
2205
2344
  dt = h / 4;
2206
2345
  }
2207
- if (dt < 1e-14 * Math.max(tEnd, 1e-300)) { failed = true; break; }
2346
+ if (dt < floor) { failed = true; break; }
2208
2347
  continue;
2209
2348
  }
2210
2349
  } else {
@@ -2213,7 +2352,7 @@ export class Solver {
2213
2352
  if (!r.converged) {
2214
2353
  rejected++;
2215
2354
  dt = h / 4;
2216
- if (dt < 1e-14 * Math.max(tEnd, 1e-300)) { failed = true; break; }
2355
+ if (dt < floor) { failed = true; break; }
2217
2356
  continue;
2218
2357
  }
2219
2358
  p = this._predict(pred, r.bdf);
@@ -2357,6 +2496,39 @@ export class Solver {
2357
2496
  return mx;
2358
2497
  }
2359
2498
 
2499
+ // Start from another solver's state for the same device on another grid (a refined one): each
2500
+ // node's unknowns interpolated linearly in x within its region, each face's copied, and the
2501
+ // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
2502
+ _warmFrom(src) {
2503
+ const { M, u, uLo } = this, grid = this.model.grid, sg = src.model.grid;
2504
+ if (src.M !== M || sg.regionStart.length !== grid.regionStart.length) throw new Error('_warmFrom: a different device');
2505
+ const value = (b, k) => src.u[b * M + k] + src.uLo[b * M + k];
2506
+ for (let r = 0; r < grid.regionStart.length; r++) {
2507
+ let a = sg.regionStart[r];
2508
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
2509
+ const x = grid.x[g];
2510
+ while (a + 1 < sg.regionEnd[r] && sg.x[a + 1] < x) a++;
2511
+ const b = Math.min(a + 1, sg.regionEnd[r]), h = sg.x[b] - sg.x[a];
2512
+ const w = h > 0 ? Math.min(1, Math.max(0, (x - sg.x[a]) / h)) : 0;
2513
+ const ba = src.blockOfNode[a], bb = src.blockOfNode[b], bg = this.blockOfNode[g];
2514
+ for (let k = 0; k < M; k++) {
2515
+ u[bg * M + k] = (1 - w) * value(ba, k) + w * value(bb, k);
2516
+ uLo[bg * M + k] = 0;
2517
+ }
2518
+ }
2519
+ }
2520
+ for (let f = 0; f < this.nFaces; f++) {
2521
+ for (let k = 0; k < M; k++) {
2522
+ u[this.blockOfFace[f] * M + k] = value(src.blockOfFace[f], k);
2523
+ uLo[this.blockOfFace[f] * M + k] = 0;
2524
+ }
2525
+ }
2526
+ this.termV.set(src.termV);
2527
+ this.solvedV = src.solvedV;
2528
+ this.time = src.time;
2529
+ this.computeConcentrations();
2530
+ }
2531
+
2360
2532
  _snapshot() {
2361
2533
  return {
2362
2534
  u: Float64Array.from(this.u),
@@ -2364,6 +2536,7 @@ export class Solver {
2364
2536
  cOld: Float64Array.from(this.cOld),
2365
2537
  time: this.time,
2366
2538
  lastDt: this.lastDt,
2539
+ atSteady: this.atSteady,
2367
2540
  contactDStart: this.contactDStart,
2368
2541
  contactDOld: this.contactDOld,
2369
2542
  contactDEnd: this.contactDEnd,
@@ -2380,6 +2553,7 @@ export class Solver {
2380
2553
  this.cOld.set(s.cOld);
2381
2554
  this.time = s.time;
2382
2555
  this.lastDt = s.lastDt;
2556
+ this.atSteady = s.atSteady;
2383
2557
  this.contactDStart = s.contactDStart;
2384
2558
  this.contactDOld = s.contactDOld;
2385
2559
  this.contactDEnd = s.contactDEnd;
@@ -2734,6 +2908,17 @@ export class Solver {
2734
2908
  return Number.isFinite(dMin) ? (L * L) / dMin : 1;
2735
2909
  }
2736
2910
 
2911
+ // The fastest diffusion time: across the smallest cell, at the largest D.
2912
+ fastestTime() {
2913
+ let dMax = 0;
2914
+ for (const mat of this.model.materials) {
2915
+ for (let i = 0; i < this.n; i++) if (mat.present[i]) dMax = Math.max(dMax, mat.D[i]);
2916
+ }
2917
+ let h = Infinity;
2918
+ for (const s of this.model.grid.segLength) if (s > 0) h = Math.min(h, s);
2919
+ return dMax > 0 && Number.isFinite(h) ? (h * h) / dMax : this.slowestTime();
2920
+ }
2921
+
2737
2922
  /**
2738
2923
  * Steady state.
2739
2924
  * - If every species stretch is fed by a contact, nothing is conserved on its own, so the
@@ -2744,8 +2929,18 @@ export class Solver {
2744
2929
  * If Newton fails, dt ramps up from a small value (pseudo-transient continuation) instead.
2745
2930
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
2746
2931
  */
2932
+ // Steady state from the present one; `atSteady` says whether the state is one (until a step or
2933
+ // a change of drive).
2747
2934
  solveSteady(opts = {}) {
2935
+ const r = this._steadyFromHere(opts);
2936
+ this.atSteady = r.converged;
2937
+ return r;
2938
+ }
2939
+
2940
+ _steadyFromHere(opts) {
2748
2941
  this.conditioning = null;
2942
+ this.computeConcentrations();
2943
+ this._captureLocal(); // immobile combinations keep, node by node, what they hold now
2749
2944
  this.sourceTime = this.time;
2750
2945
  // Continuation applies where the right terminal is held at a voltage, against a held left one.
2751
2946
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
@@ -2921,8 +3116,10 @@ export class Solver {
2921
3116
  history.push({ dt, converged: r.converged, iterations: r.iterations, maxStep: 3 });
2922
3117
  }
2923
3118
  if (!r.converged) {
3119
+ // Pseudo-transient continuation: down from a slow time scale until a step converges,
3120
+ // as far as the fastest (slow ions mustn't stop it short of what a cold start needs).
2924
3121
  dt = dt >= giant ? tau * 1e-6 : dt / 4;
2925
- if (dt < tau * 1e-15) break;
3122
+ if (dt < Math.min(tau * 1e-15, this.fastestTime() * 1e-2)) break;
2926
3123
  continue;
2927
3124
  }
2928
3125
  if (dt === Infinity) {
package/src/stack.js CHANGED
@@ -59,7 +59,7 @@ export function layer(material, length, more = {}) {
59
59
 
60
60
  // A drive: a number is a held voltage, a waveform { t, values } a voltage waveform, and an
61
61
  // object with V, I or R is the drive itself.
62
- function drive(d, path) {
62
+ export function drive(d, path) {
63
63
  if (d === undefined) return { V: 0 };
64
64
  if (typeof d === 'number') return { V: d };
65
65
  if (isObject(d) && Array.isArray(d.t)) return { V: d };
package/src/traces.js CHANGED
@@ -62,7 +62,11 @@ export function typeset(name, z) {
62
62
  * its standard level V°_i (`standard`, the band edge for e⁻ and h⁺), optionally φ and
63
63
  * half-reactions' levels. Doubled interface nodes share an x, so steps draw as vertical lines.
64
64
  * @param {import('./types.js').Solution} sol
65
- * @param {{ species?: string[], standard?: boolean, phi?: boolean | string[],
65
+ * With `energy: true` it's the familiar band diagram instead, energy up: electrons and holes as
66
+ * electron energies in eV (E = −V: E_c and E_v from the standard levels, E_Fn and E_Fp, or E_F
67
+ * where only electrons are present, from the levels), with any redox levels and φ (as −qφ) turned
68
+ * over too. Only electrons and holes have bands; ions are refused (draw them as species voltages).
69
+ * @param {{ species?: string[], standard?: boolean, phi?: boolean | string[], energy?: boolean,
66
70
  * levels?: { half: { equation: string, fixed?: Record<string, number> }, label?: string, standard?: boolean }[],
67
71
  * labels?: Record<string, string>, shifts?: Record<string, number> }} [opts]
68
72
  * species to show (default: every charged one), whether to show standard levels (default true)
@@ -73,31 +77,40 @@ export function typeset(name, z) {
73
77
  * @returns {{ x: Float64Array, series: Trace[], regions: { name: string, material: string, x0: number, x1: number }[],
74
78
  * faces: number[], range: [number, number] }}
75
79
  */
76
- export function traces(sol, { species, standard = true, phi = false, levels = [], labels = {}, shifts = {} } = {}) {
80
+ export function traces(sol, { species, standard = true, phi = false, energy = false, levels = [], labels = {}, shifts = {} } = {}) {
77
81
  const names = Object.keys(sol.V);
78
82
  const role = (name) => speciesRole(sol, name).role;
79
83
  const slotOf = (name) => speciesRole(sol, name).slot;
80
- const shown = species ?? names.filter((name) => finite(sol.V[name]));
84
+ const carriers = ['e-', 'h+'];
85
+ const shown = species ?? names.filter((name) => finite(sol.V[name]) && (!energy || carriers.includes(name)));
86
+ if (energy) {
87
+ for (const name of shown) {
88
+ if (!carriers.includes(name)) throw new Error(`traces: '${name}' has no band to draw as an energy; draw it as a species voltage (energy: false)`);
89
+ }
90
+ }
91
+ const sign = energy ? -1 : 1, unit = energy ? 'eV' : 'V';
92
+ const bipolar = names.includes('h+');
93
+ const energyLabels = { 'V:e-': bipolar ? 'E_{Fn}' : 'E_F', 'Vstd:e-': 'E_c', 'V:h+': 'E_{Fp}', 'Vstd:h+': 'E_v' };
81
94
  const zOf = Object.fromEntries(sol.species.map((sp) => [sp.name, sp.z]));
82
95
  const pretty = (name) => (name in zOf ? typeset(name, zOf[name]) : name);
83
96
  const series = [];
84
97
  for (const name of shown) {
85
98
  if (!names.includes(name)) throw new Error(`traces: no charged species '${name}' in the solution`);
86
99
  const shift = shifts[name] ?? 0;
87
- const at = (y) => (shift ? y.map((v) => v + shift) : y);
100
+ const at = (y) => (shift || energy ? Array.from(y, (v) => sign * v + shift) : y);
88
101
  const common = { species: name, role: role(name), slot: slotOf(name), ...(shift ? { shift } : {}) };
89
- if (finite(sol.V[name])) series.push({ id: `V:${name}`, label: `V_{${pretty(name)}}`, kind: 'level', ...common, y: at(sol.V[name]) });
90
- if (standard && finite(sol.Vstd[name])) series.push({ id: `Vstd:${name}`, label: `V°_{${pretty(name)}}`, kind: 'standard', ...common, y: at(sol.Vstd[name]) });
102
+ if (finite(sol.V[name])) series.push({ id: `V:${name}`, label: energy ? energyLabels[`V:${name}`] : `V_{${pretty(name)}}`, kind: 'level', ...common, y: at(sol.V[name]) });
103
+ if (standard && finite(sol.Vstd[name])) series.push({ id: `Vstd:${name}`, label: energy ? energyLabels[`Vstd:${name}`] : `V°_{${pretty(name)}}`, kind: 'standard', ...common, y: at(sol.Vstd[name]) });
91
104
  }
92
105
  if (Array.isArray(phi)) {
93
106
  const known = sol.regions.flatMap((r) => [r.name, r.material]);
94
107
  for (const name of phi) if (!known.includes(name)) throw new Error(`traces: no region or material '${name}' to draw φ in (${[...new Set(known)].join(', ')})`);
95
108
  const inside = sol.regions.map((r) => phi.includes(r.name) || phi.includes(r.material));
96
- series.push({ id: 'phi', label: 'φ', kind: 'phi', role: 'phi', slot: 0, y: Array.from(sol.phi, (v, g) => (inside[sol.region[g]] ? v : NaN)) });
97
- } else if (phi) series.push({ id: 'phi', label: 'φ', kind: 'phi', role: 'phi', slot: 0, y: sol.phi });
109
+ series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: Array.from(sol.phi, (v, g) => (inside[sol.region[g]] ? sign * v : NaN)) });
110
+ } else if (phi) series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: energy ? Array.from(sol.phi, (v) => -v) : sol.phi });
98
111
  const couples = [...new Set(levels.map((lv) => lv.half.equation))];
99
112
  levels.forEach((lv, k) => {
100
- const y = level(sol, lv.half, { standard: lv.standard });
113
+ const y = energy ? Array.from(level(sol, lv.half, { standard: lv.standard }), (v) => -v) : level(sol, lv.half, { standard: lv.standard });
101
114
  series.push({
102
115
  id: `level:${k}`,
103
116
  label: lv.label ?? `${lv.standard ? 'V° ' : ''}${lv.half.equation.split(' ').map(pretty).join(' ')}`,
@@ -109,7 +122,7 @@ export function traces(sol, { species, standard = true, phi = false, levels = []
109
122
  });
110
123
  for (const s of series) {
111
124
  if (labels[s.id] !== undefined) s.label = labels[s.id];
112
- if (s.shift) s.label += ` ⌇${s.shift > 0 ? '+' : '−'}${Math.abs(s.shift)} V`;
125
+ if (s.shift) s.label += ` ⌇${s.shift > 0 ? '+' : '−'}${Math.abs(s.shift)} ${unit}`;
113
126
  }
114
127
  let lo = Infinity, hi = -Infinity;
115
128
  for (const s of series) {
package/src/types.js CHANGED
@@ -201,7 +201,9 @@
201
201
  * @property {{ left?: { V: number, D: number, charge: number }, right?: { V: number, D: number, charge: number } }} gates
202
202
  * @property {{ name: string, V: number, flux: Record<string, number>, current: number }[]} ports what each
203
203
  * internal port brings into the device
204
- * @property {{ dipole: number, sheetCharge: number, D: number, N: Record<string, number>, rates: number[] }[]} interfaces
204
+ * @property {{ left: string, right: string, dipole: number, sheetCharge: number, D: number, N: Record<string, number>, rates: number[] }[]} interfaces
205
+ * @property {{ rate: Float64Array, regions: number[], total: number }[]} bulkReactions each bulk reaction's
206
+ * forward rate per node (mol/(m³·s), NaN where it doesn't run), and per region and in all (mol/(m²·s))
205
207
  * @property {number} charge total charge in the device, C/m²
206
208
  * @property {{ species: string, regions: number[], spectator: boolean, connected: boolean, reactive: boolean,
207
209
  * amount: number, reference: number, intake: number, drift: number }[]} conservation
@@ -209,6 +211,7 @@
209
211
  * @property {boolean} converged
210
212
  * @property {number} iterations
211
213
  * @property {number} time s
214
+ * @property {boolean} steady whether this is a converged steady state (solve()), not a transient's
212
215
  * @property {boolean} [done] advance(): whether tEnd was reached (false if the frame budget ran out)
213
216
  * @property {number} [rejected] advance(): steps rejected by error control or Newton failure
214
217
  * @property {{ t: number[], current: number[], voltage: number[], probes?: number[][] }} [trace] advance():
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @typedef {object} CheckItem
3
+ * @property {string} name what was checked: 'converged', 'warnings', 'balance', 'conservation' or 'grid'
4
+ * @property {boolean | null} ok whether it passed (null: look at it, a warning or a check that
5
+ * couldn't be made)
6
+ * @property {string} summary one line
7
+ * @property {object} [details] the numbers behind it
8
+ */
9
+ /**
10
+ * Check a device's present state (pass the solution solve() or advance() returned, for its
11
+ * convergence and warnings):
12
+ * - **converged**: the last solve converged.
13
+ * - **warnings**: the solution's warnings (unresolved double layers, steep profiles, weakly held
14
+ * parts) and the definition's likely unit slips.
15
+ * - **balance** (a steady state): each species' ledger, what comes in through each terminal and
16
+ * what each reaction (bulk or at a face) makes or uses, in mol/(m²·s) and, for a charged
17
+ * species, as a current F·|z|·rate. The terms must sum to zero; under light it's J = F(G − R),
18
+ * and it says where every carrier went.
19
+ * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
20
+ * what came in.
21
+ * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
22
+ * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
23
+ * region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
24
+ * wants less). The discretisation is second order, so the change estimates this grid's error
25
+ * (about 3/4 of it).
26
+ * @param {import('./index.js').Device} device
27
+ * @param {object} [sol] the solution to check (default: the device's present state)
28
+ * @param {{ refine?: boolean, tol?: number }} [opts]
29
+ * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
30
+ * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
31
+ * @returns {{ ok: boolean, items: CheckItem[], text: string }}
32
+ */
33
+ export function check(device: import("./index.js").Device, sol?: object, { refine, tol }?: {
34
+ refine?: boolean;
35
+ tol?: number;
36
+ }): {
37
+ ok: boolean;
38
+ items: CheckItem[];
39
+ text: string;
40
+ };
41
+ export type CheckItem = {
42
+ /**
43
+ * what was checked: 'converged', 'warnings', 'balance', 'conservation' or 'grid'
44
+ */
45
+ name: string;
46
+ /**
47
+ * whether it passed (null: look at it, a warning or a check that
48
+ * couldn't be made)
49
+ */
50
+ ok: boolean | null;
51
+ /**
52
+ * one line
53
+ */
54
+ summary: string;
55
+ /**
56
+ * the numbers behind it
57
+ */
58
+ details?: object;
59
+ };