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.
- package/README.md +14 -7
- package/docs/device.md +61 -7
- package/docs/kit.md +147 -0
- package/llms.txt +100 -15
- package/package.json +1 -1
- package/src/checks.js +302 -0
- package/src/describe.js +18 -7
- package/src/device.js +31 -4
- package/src/devices.js +135 -0
- package/src/index.js +1 -1
- package/src/junction.js +164 -0
- package/src/kit.js +4 -0
- package/src/recorder.js +89 -0
- package/src/solution.js +18 -0
- package/src/solver.js +135 -7
- package/src/stack.js +1 -1
- package/src/types.js +4 -1
- package/types/checks.d.ts +59 -0
- package/types/devices.d.ts +77 -0
- package/types/index.d.ts +3 -3
- package/types/junction.d.ts +32 -0
- package/types/kit.d.ts +4 -0
- package/types/recorder.d.ts +27 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +8 -1
- package/types/stack.d.ts +1 -0
- package/types/types.d.ts +15 -0
package/src/junction.js
ADDED
|
@@ -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) => {
|
package/src/recorder.js
ADDED
|
@@ -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.
|
|
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
|
|
2420
|
-
if (
|
|
2421
|
-
const
|
|
2422
|
-
if (quantity !== '
|
|
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
|
+
};
|