driftlet 0.5.0 → 0.7.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,164 @@
1
+ // Liquid-junction potentials from formulas, for driftlet/kit: the diffusion potential φ_R − φ_L
2
+ // between two neutral solutions, as the classical theories give it. A device simulates the actual
3
+ // junction (steady between two baths, it is Planck's; stepping from a sharp boundary, a free
4
+ // diffusion junction); these are for comparison, and for quick estimates.
5
+
6
+ import { DeviceError } from './errors.js';
7
+ import { FARADAY, GAS_CONSTANT } from './constants.js';
8
+
9
+ const fail = (message) => {
10
+ throw new DeviceError(message);
11
+ };
12
+
13
+ // The ions both solutions name, with their charge and diffusivity, and both concentrations.
14
+ function prepare(left, right, ions, what) {
15
+ const names = [...new Set([...Object.keys(left), ...Object.keys(right)])];
16
+ return names.map((name) => {
17
+ const ion = ions[name];
18
+ if (!ion || !Number.isFinite(ion.z) || !(ion.D > 0)) fail(`${what}: no { z, D } for '${name}' in the ions given`);
19
+ const cL = left[name] ?? 0, cR = right[name] ?? 0;
20
+ if (!(cL >= 0 && cR >= 0)) fail(`${what}: concentrations must be ≥ 0 (mol/m³), got ${name} ${cL} and ${cR}`);
21
+ return { name, z: ion.z, D: ion.D, cL, cR };
22
+ });
23
+ }
24
+ function neutral(list, side, what) {
25
+ let q = 0, s = 0;
26
+ for (const p of list) {
27
+ q += p.z * p[side];
28
+ s += Math.abs(p.z * p[side]);
29
+ }
30
+ if (Math.abs(q) > 1e-9 * s) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution carries a net charge (Σ z·c = ${q.toPrecision(3)} mol/m³)`);
31
+ }
32
+
33
+ /**
34
+ * Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
35
+ * mixed in one proportion at every point (every ion's mixing fraction the same curve). Exact for a single binary salt, a close estimate otherwise (it's what
36
+ * JPCalc and most electrophysiology corrections use).
37
+ * @param {Record<string, number>} left mol/m³ by ion
38
+ * @param {Record<string, number>} right mol/m³ by ion
39
+ * @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
40
+ * @param {{ T?: number }} [opts] K (default 298.15)
41
+ */
42
+ export function henderson(left, right, ions, { T = 298.15 } = {}) {
43
+ const list = prepare(left, right, ions, 'henderson');
44
+ neutral(list, 'cL', 'henderson');
45
+ neutral(list, 'cR', 'henderson');
46
+ let a = 0, b = 0, gL = 0, gR = 0;
47
+ for (const { z, D, cL, cR } of list) {
48
+ a += z * D * (cR - cL);
49
+ b += z * z * D * (cR - cL);
50
+ gL += z * z * D * cL;
51
+ gR += z * z * D * cR;
52
+ }
53
+ const VT = (GAS_CONSTANT * T) / FARADAY;
54
+ if (b === 0) return 0;
55
+ return (-VT * a * Math.log(gR / gL)) / b;
56
+ }
57
+
58
+ /**
59
+ * Planck's liquid-junction potential, φ_R − φ_L (V): the steady state of constrained diffusion,
60
+ * a junction zone held between the two solutions, electroneutral, carrying no current. Solved by
61
+ * shooting on the Nernst–Planck equations across it (its length drops out). Exact for that
62
+ * junction; equal to Henderson's for a single binary salt.
63
+ * @param {Record<string, number>} left mol/m³ by ion
64
+ * @param {Record<string, number>} right mol/m³ by ion
65
+ * @param {Record<string, { z: number, D: number }>} ions charge and diffusivity (m²/s), e.g. IONS
66
+ * @param {{ T?: number, steps?: number }} [opts] K (default 298.15); RK4 steps across (default 4000)
67
+ */
68
+ export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
69
+ const list = prepare(left, right, ions, 'planck');
70
+ neutral(list, 'cL', 'planck');
71
+ neutral(list, 'cR', 'planck');
72
+ const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
73
+ const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
74
+ const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
75
+ // Across the zone (x from 0 to 1, D's scale dropping out with its length), each ion's flux J_i is
76
+ // constant: c_i' = −J_i/D_i − z_i c_i ψ', with ψ = Fφ/RT, and neutrality fixes the field,
77
+ // ψ' = −Σ(z_i J_i/D_i)/Σ(z_i² c_i). Integrated from the left; the fluxes are chosen so the
78
+ // right comes out as given and no current flows.
79
+ const deriv = (c, J, out) => {
80
+ let num = 0, den = 0;
81
+ for (let i = 0; i < n; i++) {
82
+ num += (z[i] * J[i]) / D[i];
83
+ den += z[i] * z[i] * Math.max(c[i], 0);
84
+ }
85
+ const dpsi = -num / den;
86
+ for (let i = 0; i < n; i++) out[i] = -J[i] / D[i] - z[i] * c[i] * dpsi;
87
+ return dpsi;
88
+ };
89
+ const shoot = (J) => {
90
+ const c = cL.slice(), k1 = new Float64Array(n), k2 = new Float64Array(n), k3 = new Float64Array(n), k4 = new Float64Array(n), t = new Float64Array(n);
91
+ const h = 1 / steps;
92
+ let psi = 0;
93
+ for (let s = 0; s < steps; s++) {
94
+ const p1 = deriv(c, J, k1);
95
+ for (let i = 0; i < n; i++) t[i] = c[i] + (h / 2) * k1[i];
96
+ const p2 = deriv(t, J, k2);
97
+ for (let i = 0; i < n; i++) t[i] = c[i] + (h / 2) * k2[i];
98
+ const p3 = deriv(t, J, k3);
99
+ for (let i = 0; i < n; i++) t[i] = c[i] + h * k3[i];
100
+ const p4 = deriv(t, J, k4);
101
+ for (let i = 0; i < n; i++) c[i] += (h / 6) * (k1[i] + 2 * k2[i] + 2 * k3[i] + k4[i]);
102
+ psi += (h / 6) * (p1 + 2 * p2 + 2 * p3 + p4);
103
+ }
104
+ return { c, psi };
105
+ };
106
+ // Residuals: n − 1 ions reaching the right (the last follows by neutrality), and no current.
107
+ const residual = (J) => {
108
+ const { c, psi } = shoot(J), r = new Float64Array(n);
109
+ for (let i = 0; i < n - 1; i++) r[i] = c[i] - cR[i];
110
+ let I = 0;
111
+ for (let i = 0; i < n; i++) I += z[i] * J[i];
112
+ r[n - 1] = I / Dmax; // scaled like the concentrations
113
+ return { r, psi };
114
+ };
115
+ let J = list.map((_, i) => -D[i] * (cR[i] - cL[i])); // plain diffusion to start
116
+ let { r, psi } = residual(J);
117
+ for (let it = 0; it < 50; it++) {
118
+ const norm = Math.max(...r.map(Math.abs));
119
+ if (norm < 1e-12) break;
120
+ // Newton with a finite-difference Jacobian.
121
+ const Jac = [];
122
+ for (let k = 0; k < n; k++) {
123
+ const dJ = 1e-7 * Math.max(Math.abs(J[k]), 1e-3 * D[k]), Jp = J.slice();
124
+ Jp[k] += dJ;
125
+ const rp = residual(Jp).r;
126
+ Jac.push(Array.from(rp, (v, i) => (v - r[i]) / dJ));
127
+ }
128
+ const step = solveDense(Jac, r, n);
129
+ let lambda = 1, next;
130
+ for (;;) {
131
+ const Jn = J.map((v, k) => v - lambda * step[k]);
132
+ next = residual(Jn);
133
+ if (Math.max(...next.r.map(Math.abs)) < norm || lambda < 1e-3) {
134
+ J = Jn;
135
+ break;
136
+ }
137
+ lambda /= 2;
138
+ }
139
+ ({ r, psi } = next);
140
+ }
141
+ if (!(Math.max(...r.map(Math.abs)) < 1e-8)) fail('planck: the shooting did not converge');
142
+ return ((GAS_CONSTANT * T) / FARADAY) * psi;
143
+ }
144
+
145
+ // Solves Σ_k A[k][i] x_k = b_i (A given column by column), by Gaussian elimination with pivoting.
146
+ function solveDense(cols, b, n) {
147
+ const M = Array.from({ length: n }, (_, i) => [...cols.map((col) => col[i]), b[i]]);
148
+ for (let p = 0; p < n; p++) {
149
+ let best = p;
150
+ for (let i = p + 1; i < n; i++) if (Math.abs(M[i][p]) > Math.abs(M[best][p])) best = i;
151
+ [M[p], M[best]] = [M[best], M[p]];
152
+ for (let i = p + 1; i < n; i++) {
153
+ const f = M[i][p] / M[p][p];
154
+ for (let j = p; j <= n; j++) M[i][j] -= f * M[p][j];
155
+ }
156
+ }
157
+ const x = new Array(n).fill(0);
158
+ for (let i = n - 1; i >= 0; i--) {
159
+ let s = M[i][n];
160
+ for (let j = i + 1; j < n; j++) s -= M[i][j] * x[j];
161
+ x[i] = s / M[i][i];
162
+ }
163
+ return x;
164
+ }
package/src/kit.js CHANGED
@@ -11,6 +11,10 @@ export { traces, speciesRole, typeset } from './traces.js';
11
11
  export { live } from './live.js';
12
12
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from './sources.js';
13
13
  export { describe, unitWarnings } from './describe.js';
14
+ export { recorder } from './recorder.js';
15
+ export { henderson, planck } from './junction.js';
16
+ export { check } from './checks.js';
17
+ export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
14
18
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';
15
19
 
16
20
  const fail = (message) => {
@@ -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' | 'phi', 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,
@@ -124,6 +125,23 @@ export function makeSolution(solver, result = {}) {
124
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 };
125
126
  });
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 };
143
+ });
144
+
127
145
  // Total charge per area in the device (space charge plus sheet charges).
128
146
  let q = 0;
129
147
  for (let g = 0; g < nNodes; g++) {
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) };
@@ -802,6 +803,7 @@ export class Solver {
802
803
  // After the terminals' drives change (Device.set): which are floating, and the held values. A
803
804
  // change of source is a discontinuity, so time stepping restarts its order.
804
805
  redrive() {
806
+ this.atSteady = false;
805
807
  const before = this.floating;
806
808
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
807
809
  if (this.floating.length !== before.length) this.deltaV = null;
@@ -993,6 +995,22 @@ export class Solver {
993
995
  }
994
996
  }
995
997
  }
998
+ // A floating contact facing a held one starts where the start's own composition puts it: the
999
+ // held voltage plus the difference between the two terminal species' levels beside each
1000
+ // contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
1001
+ // that a transient's first reading means something.
1002
+ const level = (side) => {
1003
+ const ct = model.contacts[side], i = ct.terminal;
1004
+ if (i === null || ct.species[i].type !== 'equilibrium' || z[i] === 0) return NaN;
1005
+ const b = this.blockOfNode[side === 'left' ? 0 : grid.nNodes - 1];
1006
+ return (this.VT * u[b * M + 1 + i]) / z[i] - ct.species[i].offset;
1007
+ };
1008
+ for (const k of this.floating) {
1009
+ const t = this.terms[k], held = k === 0 ? 1 : 0;
1010
+ if (t.kind === 'port' || held >= this.terms.length || this.floating.includes(held)) continue;
1011
+ const shift = level(t.side) - level(this.terms[held].side);
1012
+ if (Number.isFinite(shift)) this.termV[k] = this.termV[held] + shift;
1013
+ }
996
1014
  this.computeConcentrations();
997
1015
  }
998
1016
 
@@ -1536,6 +1554,27 @@ export class Solver {
1536
1554
  res[R[o]] = -deta; // μ̄ continuous
1537
1555
  this._j(bf, r, bR, r, 1);
1538
1556
  this._j(bf, r, bL, r, -1);
1557
+ } else if (type === 'permeability') {
1558
+ // Electrodiffusion through a thin membrane in a constant field: Scharfetter–Gummel across
1559
+ // the face with D/h → P, its drift taken over the face's whole jump in the standard level
1560
+ // zφ̂ + μ°/RT − ln c_ref (the φ jump of a capacitive face, and any step between the two
1561
+ // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
1562
+ // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
1563
+ // and it's exactly zero where μ̄ is level.
1564
+ const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], P = itf.links[i].P;
1565
+ const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
1566
+ const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
1567
+ const E = Math.expm1(-deta); // η_R − η_L
1568
+ const gBc = P * bernoulli(d) * this.c[kl];
1569
+ const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
1570
+ res[R[o]] = u[o] + gBc * E;
1571
+ this._j(bf, r, bf, r, 1);
1572
+ this._j(bf, r, bL, r, -gBc);
1573
+ this._j(bf, r, bR, r, gBc * (E + 1));
1574
+ if (zi !== 0) {
1575
+ this._j(bf, r, bL, 0, zi * dNdd - zi * gBc * E);
1576
+ this._j(bf, r, bR, 0, -zi * dNdd);
1577
+ }
1539
1578
  } else {
1540
1579
  // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
1541
1580
  const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
@@ -1715,6 +1754,34 @@ export class Solver {
1715
1754
  d[0] = -this._Kz(g, k);
1716
1755
  }
1717
1756
 
1757
+ // A bulk reaction's rate at node g (mol/(m³·s), forward), as assembled: for the solution.
1758
+ bulkRate(rx, g) {
1759
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1760
+ let aHi = rx.fixedA, aLo = 0;
1761
+ for (let p = 0; p < rx.sp.length; p++) {
1762
+ aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
1763
+ aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
1764
+ }
1765
+ const law = rx.srh?.[this.nodeMaterial[g]];
1766
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo).rate;
1767
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
1768
+ 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]);
1769
+ return P * -Math.expm1(-(aHi + aLo));
1770
+ }
1771
+
1772
+ // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
1773
+ // rate is read against (in equilibrium the net rate is their round-off).
1774
+ bulkOneWay(rx, g) {
1775
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
1776
+ let a = rx.fixedA;
1777
+ 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]]);
1778
+ const law = rx.srh?.[this.nodeMaterial[g]];
1779
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).oneWay;
1780
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
1781
+ 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]);
1782
+ return P * Math.max(1, Math.exp(-a));
1783
+ }
1784
+
1718
1785
  // SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
1719
1786
  // a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}).
1720
1787
  _srhRate(law, cn, cp, a) {
@@ -1731,7 +1798,7 @@ export class Solver {
1731
1798
  const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
1732
1799
  const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
1733
1800
  const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
1734
- return { rate, Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
1801
+ 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 };
1735
1802
  }
1736
1803
 
1737
1804
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
@@ -1750,12 +1817,28 @@ export class Solver {
1750
1817
  const g = p.side ? gR : gL, b = p.side ? bR : bL, o = b * M + 1 + p.i;
1751
1818
  aHi -= p.nu * u[o];
1752
1819
  aLo -= p.nu * uLo[o];
1753
- if (rx.srh || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1820
+ if (rx.srh || rx.vmax || this.nodeConductor[g] === p.i) continue; // a conductor's carrier has activity 1
1754
1821
  const e = p.nu < 0 ? -p.nu * (1 - al) : p.nu * al;
1755
1822
  pref *= powr(c[g * n + p.i] / this.cRef[g * n + p.i], e);
1756
1823
  this._dlnc(g, p.i, e, p.side ? dR : dL);
1757
1824
  }
1758
- if (rx.srh) {
1825
+ if (rx.vmax) {
1826
+ // Saturating: r = vmax Π (c/(c + K))^{|ν|} (1 − e^{−a}); ∂ln S/∂ln c = |ν| K/(c + K).
1827
+ let S = 1;
1828
+ rx.part.forEach((p, q) => {
1829
+ if (p.nu >= 0) return;
1830
+ const g = p.side ? gR : gL, cc = c[g * n + p.i], K = rx.K[q];
1831
+ S *= powi(cc / (cc + K), -p.nu);
1832
+ this._dlnc(g, p.i, (-p.nu * K) / (cc + K), p.side ? dR : dL);
1833
+ });
1834
+ const a = aHi + aLo, back = Math.exp(-a);
1835
+ rate = rx.vmax * S * -Math.expm1(-a);
1836
+ for (let s = 0; s < M; s++) {
1837
+ dL[s] *= rate;
1838
+ dR[s] *= rate;
1839
+ }
1840
+ for (const p of rx.part) (p.side ? dR : dL)[1 + p.i] -= rx.vmax * S * back * p.nu;
1841
+ } else if (rx.srh) {
1759
1842
  // SRH, with n and p each at its own side's edge node.
1760
1843
  const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
1761
1844
  const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo);
@@ -2171,6 +2254,7 @@ export class Solver {
2171
2254
  * the start-of-step values, and dt/a0 replaces dt.
2172
2255
  */
2173
2256
  step(dt, opts = {}) {
2257
+ this.atSteady = false;
2174
2258
  const prev = this.history[0];
2175
2259
  const w = prev ? dt / prev.dt : 0;
2176
2260
  const bdf = opts.method === 'bdf2' && prev !== undefined && w <= 2;
@@ -2416,10 +2500,10 @@ export class Solver {
2416
2500
  if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
2417
2501
  const plan = probes.map((p, k) => {
2418
2502
  const path = `advance: probes[${k}]`;
2419
- const i = species.findIndex((sp) => sp.name === p?.species);
2420
- if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
2421
- const quantity = p.quantity ?? 'c';
2422
- if (quantity !== 'c' && quantity !== 'V') fail(`${path}.quantity must be 'c' (mol/m³) or 'V' (the species voltage)`);
2503
+ const quantity = p?.quantity ?? 'c';
2504
+ if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
2505
+ const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
2506
+ if (quantity !== 'phi' && i < 0) fail(`${path}.species: no species ${JSON.stringify(p?.species)}`);
2423
2507
  if (quantity === 'V' && species[i].z === 0) fail(`${path}: '${p.species}' is neutral, so it has no voltage; read 'c'`);
2424
2508
  const x = grid.x, inside = (r) => p.x >= x[grid.regionStart[r]] && p.x <= x[grid.regionEnd[r]];
2425
2509
  let r;
@@ -2439,6 +2523,7 @@ export class Solver {
2439
2523
  const out = plan.map(() => []);
2440
2524
  const { n, M } = this;
2441
2525
  const at = (q, g) => {
2526
+ if (q.quantity === 'phi') return this.phiUndefined[g] ? NaN : this.VT * (this.u[this.blockOfNode[g] * M] + this.uLo[this.blockOfNode[g] * M]);
2442
2527
  if (!this.present[g * n + q.i]) return NaN;
2443
2528
  if (q.quantity === 'c') return this.c[g * n + q.i];
2444
2529
  const o = this.blockOfNode[g] * M + 1 + q.i;
@@ -2465,6 +2550,39 @@ export class Solver {
2465
2550
  return mx;
2466
2551
  }
2467
2552
 
2553
+ // Start from another solver's state for the same device on another grid (a refined one): each
2554
+ // node's unknowns interpolated linearly in x within its region, each face's copied, and the
2555
+ // terminals' voltages. A warm start for a steady solve; nothing else is carried over.
2556
+ _warmFrom(src) {
2557
+ const { M, u, uLo } = this, grid = this.model.grid, sg = src.model.grid;
2558
+ if (src.M !== M || sg.regionStart.length !== grid.regionStart.length) throw new Error('_warmFrom: a different device');
2559
+ const value = (b, k) => src.u[b * M + k] + src.uLo[b * M + k];
2560
+ for (let r = 0; r < grid.regionStart.length; r++) {
2561
+ let a = sg.regionStart[r];
2562
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
2563
+ const x = grid.x[g];
2564
+ while (a + 1 < sg.regionEnd[r] && sg.x[a + 1] < x) a++;
2565
+ const b = Math.min(a + 1, sg.regionEnd[r]), h = sg.x[b] - sg.x[a];
2566
+ const w = h > 0 ? Math.min(1, Math.max(0, (x - sg.x[a]) / h)) : 0;
2567
+ const ba = src.blockOfNode[a], bb = src.blockOfNode[b], bg = this.blockOfNode[g];
2568
+ for (let k = 0; k < M; k++) {
2569
+ u[bg * M + k] = (1 - w) * value(ba, k) + w * value(bb, k);
2570
+ uLo[bg * M + k] = 0;
2571
+ }
2572
+ }
2573
+ }
2574
+ for (let f = 0; f < this.nFaces; f++) {
2575
+ for (let k = 0; k < M; k++) {
2576
+ u[this.blockOfFace[f] * M + k] = value(src.blockOfFace[f], k);
2577
+ uLo[this.blockOfFace[f] * M + k] = 0;
2578
+ }
2579
+ }
2580
+ this.termV.set(src.termV);
2581
+ this.solvedV = src.solvedV;
2582
+ this.time = src.time;
2583
+ this.computeConcentrations();
2584
+ }
2585
+
2468
2586
  _snapshot() {
2469
2587
  return {
2470
2588
  u: Float64Array.from(this.u),
@@ -2472,6 +2590,7 @@ export class Solver {
2472
2590
  cOld: Float64Array.from(this.cOld),
2473
2591
  time: this.time,
2474
2592
  lastDt: this.lastDt,
2593
+ atSteady: this.atSteady,
2475
2594
  contactDStart: this.contactDStart,
2476
2595
  contactDOld: this.contactDOld,
2477
2596
  contactDEnd: this.contactDEnd,
@@ -2488,6 +2607,7 @@ export class Solver {
2488
2607
  this.cOld.set(s.cOld);
2489
2608
  this.time = s.time;
2490
2609
  this.lastDt = s.lastDt;
2610
+ this.atSteady = s.atSteady;
2491
2611
  this.contactDStart = s.contactDStart;
2492
2612
  this.contactDOld = s.contactDOld;
2493
2613
  this.contactDEnd = s.contactDEnd;
@@ -2863,7 +2983,15 @@ export class Solver {
2863
2983
  * If Newton fails, dt ramps up from a small value (pseudo-transient continuation) instead.
2864
2984
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
2865
2985
  */
2986
+ // Steady state from the present one; `atSteady` says whether the state is one (until a step or
2987
+ // a change of drive).
2866
2988
  solveSteady(opts = {}) {
2989
+ const r = this._steadyFromHere(opts);
2990
+ this.atSteady = r.converged;
2991
+ return r;
2992
+ }
2993
+
2994
+ _steadyFromHere(opts) {
2867
2995
  this.conditioning = null;
2868
2996
  this.computeConcentrations();
2869
2997
  this._captureLocal(); // immobile combinations keep, node by node, what they hold now
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/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
+ };