driftlet 0.1.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/LICENSE +14 -0
- package/README.md +285 -0
- package/llms.txt +299 -0
- package/package.json +54 -0
- package/src/bernoulli.js +29 -0
- package/src/blockTridiagonal.js +492 -0
- package/src/constants.js +36 -0
- package/src/data.js +127 -0
- package/src/describe.js +190 -0
- package/src/device.js +954 -0
- package/src/equation.js +188 -0
- package/src/errors.js +8 -0
- package/src/fermi.js +106 -0
- package/src/grid.js +111 -0
- package/src/index.js +207 -0
- package/src/kit.js +76 -0
- package/src/live.js +217 -0
- package/src/merge.js +11 -0
- package/src/plot.js +267 -0
- package/src/solution.js +246 -0
- package/src/solver.js +2884 -0
- package/src/stack.js +172 -0
- package/src/statistics.js +593 -0
- package/src/traces.js +125 -0
- package/src/types.js +221 -0
- package/src/worker.js +24 -0
- package/types/bernoulli.d.ts +4 -0
- package/types/blockTridiagonal.d.ts +92 -0
- package/types/constants.d.ts +33 -0
- package/types/data.d.ts +73 -0
- package/types/describe.d.ts +15 -0
- package/types/device.d.ts +20 -0
- package/types/equation.d.ts +102 -0
- package/types/errors.d.ts +3 -0
- package/types/fermi.d.ts +3 -0
- package/types/grid.d.ts +28 -0
- package/types/index.d.ts +103 -0
- package/types/kit.d.ts +61 -0
- package/types/live.d.ts +101 -0
- package/types/merge.d.ts +1 -0
- package/types/plot.d.ts +60 -0
- package/types/solution.d.ts +18 -0
- package/types/solver.d.ts +342 -0
- package/types/stack.d.ts +59 -0
- package/types/statistics.d.ts +10 -0
- package/types/traces.d.ts +118 -0
- package/types/types.d.ts +574 -0
package/src/equation.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Reaction equations as text: 'Ag+ + e- = Ag(s)', '2 H+ + 2 e- = H2', 'e- + h+ = 0'. A
|
|
2
|
+
// definition's reactions can be written this way; they compile to signed stoichiometry (ν < 0
|
|
3
|
+
// consumed by the forward reaction, left to right as written).
|
|
4
|
+
|
|
5
|
+
import { DeviceError } from './errors.js';
|
|
6
|
+
import { FARADAY } from './constants.js';
|
|
7
|
+
|
|
8
|
+
const fail = (message) => {
|
|
9
|
+
throw new DeviceError(message);
|
|
10
|
+
};
|
|
11
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One participant of an equation as written.
|
|
15
|
+
* @typedef {object} Term
|
|
16
|
+
* @property {string} name the participant as written (with any trailing parenthesis)
|
|
17
|
+
* @property {number} nu signed coefficient (< 0 on the left of the equation)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse an equation into its terms, in order. The two sides are separated by `=` (or `⇌`, `<=>`,
|
|
22
|
+
* `->`, `→`), terms by ` + ` (spaces around the plus, which keeps it apart from charges), and a
|
|
23
|
+
* side with nothing on it is `0` or `∅`. A coefficient is an integer followed by a space
|
|
24
|
+
* (`2 e-`), so names may start with digits (`3He`).
|
|
25
|
+
* @param {string} text
|
|
26
|
+
* @param {string} [path] for error messages
|
|
27
|
+
* @returns {Term[]}
|
|
28
|
+
*/
|
|
29
|
+
export function parseEquation(text, path = 'equation') {
|
|
30
|
+
if (typeof text !== 'string') fail(`${path} must be a string like 'Ag+ + e- = Ag(s)'`);
|
|
31
|
+
const sides = text.trim().split(/\s+(?:=|⇌|<=>|->|→)\s+/);
|
|
32
|
+
if (sides.length !== 2) fail(`${path}: ${JSON.stringify(text)} needs exactly one '=' with a space on either side`);
|
|
33
|
+
const terms = [];
|
|
34
|
+
sides.forEach((side, s) => {
|
|
35
|
+
if (side === '0' || side === '∅') return;
|
|
36
|
+
for (const raw of side.split(/\s+\+\s+/)) {
|
|
37
|
+
const m = /^(?:(\d+)\s+)?(\S+)$/.exec(raw.trim());
|
|
38
|
+
if (!m) fail(`${path}: can't read ${JSON.stringify(raw.trim())} in ${JSON.stringify(text)} (terms are separated by ' + ', with spaces)`);
|
|
39
|
+
const nu = m[1] === undefined ? 1 : Number(m[1]);
|
|
40
|
+
if (nu === 0) fail(`${path}: a zero coefficient in ${JSON.stringify(text)}`);
|
|
41
|
+
terms.push({ name: m[2], nu: s === 0 ? -nu : nu });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
if (terms.length === 0) fail(`${path}: ${JSON.stringify(text)} has no participants`);
|
|
45
|
+
return terms;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The signed stoichiometry `{ name: ν }` of a homogeneous reaction, as a bulk reaction's `nu`.
|
|
50
|
+
* A participant on both sides is an error: give the net reaction.
|
|
51
|
+
* @param {string} text
|
|
52
|
+
* @param {string} [path]
|
|
53
|
+
* @returns {Record<string, number>}
|
|
54
|
+
*/
|
|
55
|
+
export function stoichiometry(text, path = 'equation') {
|
|
56
|
+
const nu = {};
|
|
57
|
+
const side = {};
|
|
58
|
+
for (const t of parseEquation(text, path)) {
|
|
59
|
+
if (side[t.name] !== undefined && side[t.name] !== Math.sign(t.nu)) {
|
|
60
|
+
fail(`${path}: '${t.name}' appears on both sides of ${JSON.stringify(text)}; give the net reaction`);
|
|
61
|
+
}
|
|
62
|
+
side[t.name] = Math.sign(t.nu);
|
|
63
|
+
nu[t.name] = (nu[t.name] ?? 0) + t.nu;
|
|
64
|
+
}
|
|
65
|
+
return nu;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Which side of a face each participant of an equation is on: the side whose material holds it
|
|
70
|
+
* (on a conductor's side, only its carrier). A species held on both sides is labelled with its
|
|
71
|
+
* side, 'Li+(left)', or its material, 'Li+(graphite)'. Participants in `fixed` (fixed-activity
|
|
72
|
+
* neutrals) go beside a conductor if there is one, else on the left (their side doesn't matter).
|
|
73
|
+
* @param {Term[]} terms
|
|
74
|
+
* @param {{ name: string, holds: (species: string) => boolean, conductor?: boolean }} left
|
|
75
|
+
* @param {{ name: string, holds: (species: string) => boolean, conductor?: boolean }} right
|
|
76
|
+
* @param {Record<string, number> | undefined} fixed
|
|
77
|
+
* @param {string} path
|
|
78
|
+
* @returns {{ left: Record<string, number>, right: Record<string, number> }}
|
|
79
|
+
*/
|
|
80
|
+
export function faceSides(terms, left, right, fixed, path) {
|
|
81
|
+
const sides = { left: {}, right: {} };
|
|
82
|
+
const put = (side, name, nu) => (sides[side][name] = (sides[side][name] ?? 0) + nu);
|
|
83
|
+
const fixedSide = right.conductor && !left.conductor ? 'right' : 'left';
|
|
84
|
+
for (const t of terms) {
|
|
85
|
+
if (fixed?.[t.name] !== undefined) {
|
|
86
|
+
put(fixedSide, t.name, t.nu);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const inL = left.holds(t.name), inR = right.holds(t.name);
|
|
90
|
+
if (inL && inR) {
|
|
91
|
+
fail(
|
|
92
|
+
`${path}: '${t.name}' is in both '${left.name}' and '${right.name}', so say which side: ` +
|
|
93
|
+
`'${t.name}(left)' or '${t.name}(right)'` + (left.name !== right.name ? `, or '${t.name}(${left.name})', '${t.name}(${right.name})'` : ''),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (inL || inR) {
|
|
97
|
+
put(inL ? 'left' : 'right', t.name, t.nu);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const m = /^(.+)\(([^()]+)\)$/.exec(t.name);
|
|
101
|
+
if (m) {
|
|
102
|
+
const [, base, label] = m;
|
|
103
|
+
// The words left and right first; then a material's name, if it names one side only.
|
|
104
|
+
const side =
|
|
105
|
+
label === 'left' || label === 'right'
|
|
106
|
+
? label
|
|
107
|
+
: label === left.name && label !== right.name
|
|
108
|
+
? 'left'
|
|
109
|
+
: label === right.name && label !== left.name
|
|
110
|
+
? 'right'
|
|
111
|
+
: null;
|
|
112
|
+
if (side) {
|
|
113
|
+
const at = side === 'left' ? left : right;
|
|
114
|
+
if (!at.holds(base)) fail(`${path}: '${base}' is absent from '${at.name}' (${side} of the face)`);
|
|
115
|
+
put(side, base, t.nu);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
fail(
|
|
120
|
+
`${path}: '${t.name}' is in neither '${left.name}' nor '${right.name}'; if it's a fixed-activity neutral, give its μ in fixed` +
|
|
121
|
+
(/^\d+\D/.test(t.name) ? `; if ${t.name.match(/^\d+/)[0]} is a coefficient, put a space after it` : ''),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return sides;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A half-reaction: an equation with electrons `e-` on one side, and the μ (J/mol) of any
|
|
129
|
+
* participant that isn't a species (a fixed-activity neutral, such as a solid metal). It's plain
|
|
130
|
+
* data, `{ equation, fixed }`: spread it into a face reaction with its kinetics,
|
|
131
|
+
* `{ ...silver, k0: 1e-3 }`, or pass it to `level()`.
|
|
132
|
+
* @param {string} equation e.g. 'Ag+ + e- = Ag(s)'
|
|
133
|
+
* @param {Record<string, number>} [fixed] μ of the fixed participants, J/mol
|
|
134
|
+
* @returns {{ equation: string, fixed?: Record<string, number> }}
|
|
135
|
+
*/
|
|
136
|
+
export function half(equation, fixed) {
|
|
137
|
+
const terms = parseEquation(equation, 'half');
|
|
138
|
+
const electrons = terms.filter((t) => t.name === 'e-');
|
|
139
|
+
if (electrons.length !== 1) fail(`half: ${JSON.stringify(equation)} needs electrons, 'e-', on exactly one side`);
|
|
140
|
+
if (fixed !== undefined && !isObject(fixed)) fail('half: fixed must map participants to their μ (J/mol)');
|
|
141
|
+
for (const [name, mu] of Object.entries(fixed ?? {})) {
|
|
142
|
+
if (!terms.some((t) => t.name === name)) fail(`half: fixed.${name} isn't in ${JSON.stringify(equation)}`);
|
|
143
|
+
if (!Number.isFinite(mu)) fail(`half: fixed.${name} must be a finite μ (J/mol)`);
|
|
144
|
+
}
|
|
145
|
+
return fixed === undefined ? { equation } : { equation, fixed: { ...fixed } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The standard hydrogen electrode, 2 H⁺ + 2 e⁻ ⇌ H₂, with μ(H₂) = 0 (the element in its standard
|
|
150
|
+
* state, on the usual table convention). Needs `H+` among the species.
|
|
151
|
+
*/
|
|
152
|
+
export const SHE = Object.freeze(half('2 H+ + 2 e- = H2', { H2: 0 }));
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The electronic level a half-reaction implies at each node, as an electron voltage
|
|
156
|
+
* V = −μ̄_e/F: where an electrode exchanging electrons by that reaction would sit in equilibrium
|
|
157
|
+
* with the local composition (the couple's redox level). With `standard: true`, every
|
|
158
|
+
* species is taken at its reference concentration (its standard level μ° + zFφ), so it's the
|
|
159
|
+
* standard level: for `SHE`, V°_e⁻(SHE) = φ + μ°_H⁺/F. `NaN` where a participant is absent.
|
|
160
|
+
* @param {{ mu: Record<string, ArrayLike<number>>, muStd: Record<string, ArrayLike<number>> }} sol a solution
|
|
161
|
+
* @param {{ equation: string, fixed?: Record<string, number> }} halfReaction from `half()`
|
|
162
|
+
* @param {{ standard?: boolean }} [opts]
|
|
163
|
+
* @returns {Float64Array}
|
|
164
|
+
*/
|
|
165
|
+
export function level(sol, halfReaction, { standard = false } = {}) {
|
|
166
|
+
if (!isObject(halfReaction)) fail('level: give a half-reaction from half()');
|
|
167
|
+
const { equation, fixed = {} } = halfReaction;
|
|
168
|
+
const terms = parseEquation(equation, 'level');
|
|
169
|
+
const source = standard ? sol.muStd : sol.mu;
|
|
170
|
+
let nuE = 0, fixedSum = 0;
|
|
171
|
+
const parts = [];
|
|
172
|
+
for (const t of terms) {
|
|
173
|
+
if (t.name === 'e-') nuE += t.nu;
|
|
174
|
+
else if (fixed[t.name] !== undefined) fixedSum += t.nu * fixed[t.name];
|
|
175
|
+
else if (source[t.name]) parts.push({ nu: t.nu, mu: source[t.name] });
|
|
176
|
+
else fail(`level: '${t.name}' is neither a species of the solution nor in the half-reaction's fixed`);
|
|
177
|
+
}
|
|
178
|
+
if (nuE === 0) fail(`level: ${JSON.stringify(equation)} has no net electrons`);
|
|
179
|
+
const n = sol.mu[Object.keys(sol.mu)[0]].length;
|
|
180
|
+
const V = new Float64Array(n);
|
|
181
|
+
for (let g = 0; g < n; g++) {
|
|
182
|
+
// Σ ν μ̄ = 0 at equilibrium, so μ̄_e = −(Σ_others ν μ̄)/ν_e.
|
|
183
|
+
let s = fixedSum;
|
|
184
|
+
for (const p of parts) s += p.nu * p.mu[g];
|
|
185
|
+
V[g] = s / (nuE * FARADAY);
|
|
186
|
+
}
|
|
187
|
+
return V;
|
|
188
|
+
}
|
package/src/errors.js
ADDED
package/src/fermi.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Complete Fermi–Dirac integrals of order 1/2 and −1/2, normalised so that 𝓕_j(x) → e^x as
|
|
2
|
+
// x → −∞:
|
|
3
|
+
// 𝓕_j(x) = (1/Γ(j+1)) ∫₀^∞ t^j / (1 + e^{t−x}) dt, d𝓕_j/dx = 𝓕_{j−1}.
|
|
4
|
+
// Relative accuracy ~1e-14 everywhere:
|
|
5
|
+
// x < −2 alternating series Σ (−1)^{k+1} e^{kx} / k^{j+1}
|
|
6
|
+
// −2 ≤ x ≤ 50 piecewise Chebyshev fits, built on first use from an exact quadrature
|
|
7
|
+
// x > 50 Sommerfeld's asymptotic expansion (its error is ~e^{−x})
|
|
8
|
+
|
|
9
|
+
const INTERVALS = [-2, 1, 5, 12, 25, 50];
|
|
10
|
+
const NCHEB = 24;
|
|
11
|
+
const SQRT_PI = Math.sqrt(Math.PI);
|
|
12
|
+
|
|
13
|
+
// Reference values by quadrature. With t = u² the integrand is even and smooth in u, so the
|
|
14
|
+
// trapezoid rule converges geometrically; h = 0.02 already reaches round-off.
|
|
15
|
+
function quadrature(j, x) {
|
|
16
|
+
const h = 0.02;
|
|
17
|
+
const umax = Math.sqrt(Math.max(x, 0) + 45);
|
|
18
|
+
const f = (u) => {
|
|
19
|
+
const p = j === 0.5 ? 2 * u * u : 2;
|
|
20
|
+
const e = u * u - x;
|
|
21
|
+
return e > 0 ? (p * Math.exp(-e)) / (1 + Math.exp(-e)) : p / (1 + Math.exp(e));
|
|
22
|
+
};
|
|
23
|
+
let s = f(0) / 2;
|
|
24
|
+
for (let k = 1; k * h <= umax; k++) s += f(k * h);
|
|
25
|
+
return (s * h) / (j === 0.5 ? SQRT_PI / 2 : SQRT_PI);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function chebFit(j, a, b) {
|
|
29
|
+
const v = new Float64Array(NCHEB);
|
|
30
|
+
for (let k = 0; k < NCHEB; k++) {
|
|
31
|
+
const t = Math.cos((Math.PI * (k + 0.5)) / NCHEB);
|
|
32
|
+
v[k] = quadrature(j, 0.5 * (a + b) + 0.5 * (b - a) * t);
|
|
33
|
+
}
|
|
34
|
+
const c = new Float64Array(NCHEB);
|
|
35
|
+
for (let m = 0; m < NCHEB; m++) {
|
|
36
|
+
let s = 0;
|
|
37
|
+
for (let k = 0; k < NCHEB; k++) s += v[k] * Math.cos((Math.PI * m * (k + 0.5)) / NCHEB);
|
|
38
|
+
c[m] = (2 / NCHEB) * s;
|
|
39
|
+
}
|
|
40
|
+
c[0] /= 2;
|
|
41
|
+
return c;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let fits = null; // [j = 1/2, j = −1/2] × intervals
|
|
45
|
+
function tables() {
|
|
46
|
+
if (!fits) {
|
|
47
|
+
fits = [0.5, -0.5].map((j) => INTERVALS.slice(0, -1).map((a, k) => chebFit(j, a, INTERVALS[k + 1])));
|
|
48
|
+
}
|
|
49
|
+
return fits;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clenshaw(c, a, b, x) {
|
|
53
|
+
const t = (2 * x - a - b) / (b - a);
|
|
54
|
+
let b1 = 0, b2 = 0;
|
|
55
|
+
for (let m = c.length - 1; m >= 1; m--) {
|
|
56
|
+
const tmp = 2 * t * b1 - b2 + c[m];
|
|
57
|
+
b2 = b1;
|
|
58
|
+
b1 = tmp;
|
|
59
|
+
}
|
|
60
|
+
return t * b1 - b2 + c[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function series(j, x) {
|
|
64
|
+
const e = Math.exp(x);
|
|
65
|
+
let term = e, s = 0;
|
|
66
|
+
for (let k = 1; k <= 40; k++) {
|
|
67
|
+
const t = term / k ** (j + 1);
|
|
68
|
+
s += k % 2 ? t : -t;
|
|
69
|
+
if (t < 1e-17 * s) break;
|
|
70
|
+
term *= e;
|
|
71
|
+
}
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// η(2k) = (1 − 2^{1−2k}) ζ(2k), the Dirichlet eta function at even arguments.
|
|
76
|
+
const ETA = [Math.PI ** 2 / 12, (7 * Math.PI ** 4) / 720, (31 * Math.PI ** 6) / 30240, 0.99623300185264789922, 0.99903950759827156564, 0.99975768514385819085];
|
|
77
|
+
|
|
78
|
+
function asymptotic(j, x) {
|
|
79
|
+
// 𝓕_j(x) ~ x^{j+1}/Γ(j+2) · [1 + Σ_k 2η(2k) (j+1)(j)…(j+2−2k) x^{−2k}]
|
|
80
|
+
let s = 1, fall = 1;
|
|
81
|
+
const x2 = 1 / (x * x);
|
|
82
|
+
let p = 1;
|
|
83
|
+
for (let k = 1; k <= ETA.length; k++) {
|
|
84
|
+
fall *= (j + 1 - (2 * k - 2)) * (j + 1 - (2 * k - 1));
|
|
85
|
+
p *= x2;
|
|
86
|
+
s += 2 * ETA[k - 1] * fall * p;
|
|
87
|
+
}
|
|
88
|
+
const gamma = j === 0.5 ? (3 * SQRT_PI) / 4 : SQRT_PI / 2; // Γ(j + 2)
|
|
89
|
+
return (x ** (j + 1) / gamma) * s;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fermi(j, x) {
|
|
93
|
+
if (x < INTERVALS[0]) return series(j, x);
|
|
94
|
+
if (x > INTERVALS[INTERVALS.length - 1]) return asymptotic(j, x);
|
|
95
|
+
const t = tables()[j === 0.5 ? 0 : 1];
|
|
96
|
+
let k = 0;
|
|
97
|
+
while (x > INTERVALS[k + 1]) k++;
|
|
98
|
+
return clenshaw(t[k], INTERVALS[k], INTERVALS[k + 1], x);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 𝓕_{1/2}(x), normalised so that it tends to e^x for x → −∞. */
|
|
102
|
+
export const fermiHalf = (x) => fermi(0.5, x);
|
|
103
|
+
/** 𝓕_{−1/2}(x) = d𝓕_{1/2}/dx. */
|
|
104
|
+
export const fermiMinusHalf = (x) => fermi(-0.5, x);
|
|
105
|
+
/** Reference quadrature, for tests. */
|
|
106
|
+
export const fermiQuadrature = quadrature;
|
package/src/grid.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// 1D grid with graded spacing and doubled nodes at region boundaries.
|
|
2
|
+
//
|
|
3
|
+
// Each region gets its own run of nodes from its left end to its right end, so every region
|
|
4
|
+
// boundary appears twice: the last node of region r and the first node of region r+1 sit at
|
|
5
|
+
// the same x, joined by a zero-length "link" segment. Ordinary segments therefore always lie
|
|
6
|
+
// inside a single region, and all material steps happen across links.
|
|
7
|
+
//
|
|
8
|
+
// Spacing grows geometrically (ratio `ratio`) from `hmin` at each region end, capped at
|
|
9
|
+
// `hmax`, then is scaled so the cells fill the region exactly. A region may instead give its
|
|
10
|
+
// cell widths outright (`cells`), as a metal region does: one cell, exact for Ohm's law.
|
|
11
|
+
|
|
12
|
+
const DEFAULTS = Object.freeze({ ratio: 1.2, minCells: 8 });
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cell widths for one region: fine at both ends, growing toward the middle.
|
|
16
|
+
* @param {number} length m
|
|
17
|
+
* @param {import('./types.js').GridOptions} [opts]
|
|
18
|
+
* @returns {number[]} widths summing to `length`
|
|
19
|
+
*/
|
|
20
|
+
export function gradedCells(length, { hmin, hmax, ratio = DEFAULTS.ratio, minCells = DEFAULTS.minCells, hLeft, hRight } = {}) {
|
|
21
|
+
if (!(length > 0)) throw new RangeError(`region length must be > 0, got ${length}`);
|
|
22
|
+
if (!(ratio >= 1)) throw new RangeError(`grid ratio must be ≥ 1, got ${ratio}`);
|
|
23
|
+
const cap = Math.min(hmax ?? length / minCells, length / minCells);
|
|
24
|
+
let hl = Math.min(hLeft ?? hmin ?? cap, cap);
|
|
25
|
+
let hr = Math.min(hRight ?? hmin ?? cap, cap);
|
|
26
|
+
if (!(hl > 0 && hr > 0)) throw new RangeError('grid spacing must be > 0');
|
|
27
|
+
|
|
28
|
+
const left = [], right = [];
|
|
29
|
+
let sum = 0;
|
|
30
|
+
// The tolerance stops round-off in the running sum from adding a sliver cell.
|
|
31
|
+
while (sum < length * (1 - 1e-12)) {
|
|
32
|
+
if (hl <= hr) {
|
|
33
|
+
left.push(hl);
|
|
34
|
+
sum += hl;
|
|
35
|
+
hl = Math.min(hl * ratio, cap);
|
|
36
|
+
} else {
|
|
37
|
+
right.push(hr);
|
|
38
|
+
sum += hr;
|
|
39
|
+
hr = Math.min(hr * ratio, cap);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const cells = left.concat(right.reverse());
|
|
43
|
+
const scale = length / sum;
|
|
44
|
+
for (let k = 0; k < cells.length; k++) cells[k] *= scale;
|
|
45
|
+
return cells;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the device grid.
|
|
50
|
+
* @param {{length: number, grid?: object, cells?: number[]}[]} regions in order, left to right
|
|
51
|
+
* @param {object} [opts] defaults for every region: { hmin, hmax, ratio, minCells }
|
|
52
|
+
*/
|
|
53
|
+
export function buildGrid(regions, opts = {}) {
|
|
54
|
+
if (!Array.isArray(regions) || regions.length === 0) throw new Error('grid needs at least one region');
|
|
55
|
+
|
|
56
|
+
const perRegion = regions.map((reg) => reg.cells ?? gradedCells(reg.length, { ...opts, ...(reg.grid ?? {}) }));
|
|
57
|
+
const nNodes = perRegion.reduce((s, cells) => s + cells.length + 1, 0);
|
|
58
|
+
const nSeg = nNodes - 1;
|
|
59
|
+
|
|
60
|
+
const x = new Float64Array(nNodes);
|
|
61
|
+
const nodeRegion = new Int32Array(nNodes);
|
|
62
|
+
const segLength = new Float64Array(nSeg);
|
|
63
|
+
const segRegion = new Int32Array(nSeg); // −1 for a link across a region boundary
|
|
64
|
+
const segFace = new Int32Array(nSeg); // face index for links, −1 otherwise
|
|
65
|
+
const vol = new Float64Array(nNodes); // box volume per unit area
|
|
66
|
+
const regionStart = new Int32Array(regions.length);
|
|
67
|
+
const regionEnd = new Int32Array(regions.length); // inclusive
|
|
68
|
+
|
|
69
|
+
let node = 0, x0 = 0;
|
|
70
|
+
for (let r = 0; r < regions.length; r++) {
|
|
71
|
+
const cells = perRegion[r];
|
|
72
|
+
regionStart[r] = node;
|
|
73
|
+
let xi = x0;
|
|
74
|
+
for (let k = 0; k <= cells.length; k++) {
|
|
75
|
+
x[node] = xi;
|
|
76
|
+
nodeRegion[node] = r;
|
|
77
|
+
if (k < cells.length) {
|
|
78
|
+
segLength[node] = cells[k];
|
|
79
|
+
segRegion[node] = r;
|
|
80
|
+
segFace[node] = -1;
|
|
81
|
+
vol[node] += cells[k] / 2;
|
|
82
|
+
vol[node + 1] += cells[k] / 2;
|
|
83
|
+
xi += cells[k];
|
|
84
|
+
node++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Pin the region's right end exactly, so the doubled node's x matches bit-for-bit.
|
|
88
|
+
x0 += regions[r].length;
|
|
89
|
+
x[node] = x0;
|
|
90
|
+
regionEnd[r] = node;
|
|
91
|
+
if (r < regions.length - 1) {
|
|
92
|
+
segLength[node] = 0;
|
|
93
|
+
segRegion[node] = -1;
|
|
94
|
+
segFace[node] = r;
|
|
95
|
+
node++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
nNodes,
|
|
101
|
+
x,
|
|
102
|
+
nodeRegion,
|
|
103
|
+
segLength,
|
|
104
|
+
segRegion,
|
|
105
|
+
segFace,
|
|
106
|
+
vol,
|
|
107
|
+
regionStart,
|
|
108
|
+
regionEnd,
|
|
109
|
+
length: x0,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// driftlet: 1D drift–diffusion–reaction solver.
|
|
2
|
+
|
|
3
|
+
import { normalizeDevice } from './device.js';
|
|
4
|
+
import { Solver, SolverError } from './solver.js';
|
|
5
|
+
import { makeSolution } from './solution.js';
|
|
6
|
+
import { merge } from './merge.js';
|
|
7
|
+
|
|
8
|
+
export { DeviceError, normalizeDevice } from './device.js';
|
|
9
|
+
import { normalizeDrives } from './device.js';
|
|
10
|
+
export { SolverError } from './solver.js';
|
|
11
|
+
export { buildGrid, gradedCells } from './grid.js';
|
|
12
|
+
export { BlockTridiagonal } from './blockTridiagonal.js';
|
|
13
|
+
export { bernoulli, bernoulliDerivative } from './bernoulli.js';
|
|
14
|
+
export * from './constants.js';
|
|
15
|
+
|
|
16
|
+
// Whether two definitions differ only in their terminals' drives (V, I, R on the contacts and
|
|
17
|
+
// ports). Functions (custom statistics) compare by identity.
|
|
18
|
+
function sameExceptDrives(a, b) {
|
|
19
|
+
const drive = new Set(['V', 'I', 'R']);
|
|
20
|
+
const eq = (x, y, terminal) => {
|
|
21
|
+
if (x === y) return true;
|
|
22
|
+
if (typeof x !== 'object' || typeof y !== 'object' || x === null || y === null) return false;
|
|
23
|
+
if (Array.isArray(x) !== Array.isArray(y)) return false;
|
|
24
|
+
const keys = new Set([...Object.keys(x), ...Object.keys(y)]);
|
|
25
|
+
for (const k of keys) {
|
|
26
|
+
if (terminal && drive.has(k)) continue;
|
|
27
|
+
if (!eq(x[k], y[k], false)) return false;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
};
|
|
31
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
32
|
+
for (const k of keys) {
|
|
33
|
+
if (k === 'contacts') {
|
|
34
|
+
const ca = a.contacts ?? {}, cb = b.contacts ?? {};
|
|
35
|
+
for (const side of new Set([...Object.keys(ca), ...Object.keys(cb)])) if (!eq(ca[side] ?? {}, cb[side] ?? {}, true)) return false;
|
|
36
|
+
} else if (k === 'ports') {
|
|
37
|
+
const pa = a.ports ?? [], pb = b.ports ?? [];
|
|
38
|
+
if (pa.length !== pb.length || pa.some((p, i) => !eq(p, pb[i], true))) return false;
|
|
39
|
+
} else if (!eq(a[k], b[k], false)) return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** @typedef {import('./types.js').DeviceDefinition} DeviceDefinition */
|
|
45
|
+
/** @typedef {import('./types.js').Solution} Solution */
|
|
46
|
+
|
|
47
|
+
export class Device {
|
|
48
|
+
/** @param {DeviceDefinition} def plain, serialisable device definition (see docs/device.md) */
|
|
49
|
+
constructor(def) {
|
|
50
|
+
this.def = def;
|
|
51
|
+
this.model = normalizeDevice(def);
|
|
52
|
+
this._solver = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The solver, built on first use (so construction only validates the definition). */
|
|
56
|
+
get solver() {
|
|
57
|
+
if (!this._solver) this._solver = new Solver(this.model);
|
|
58
|
+
return this._solver;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get grid() {
|
|
62
|
+
return this.model.grid;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Change part of the definition (deep-merged). A change to the terminals' drives alone (V, I,
|
|
67
|
+
* R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
|
|
68
|
+
* the warm start when the grid and species are unchanged (else restarting from the regions' c0).
|
|
69
|
+
* @param {object} patch a partial device definition, merged into the current one
|
|
70
|
+
* @returns {this}
|
|
71
|
+
*/
|
|
72
|
+
set(patch) {
|
|
73
|
+
const def = merge(this.def, patch);
|
|
74
|
+
// Only the terminals' drives changed: update them in place, keeping the solver and its state
|
|
75
|
+
// (a step in a source restarts the time stepping's order, as at any discontinuity).
|
|
76
|
+
if (this._solver && sameExceptDrives(this.def, def)) {
|
|
77
|
+
this._redefineDrives(def);
|
|
78
|
+
return this;
|
|
79
|
+
}
|
|
80
|
+
// A rebuild. Everything that can fail (validation, building the solver) happens before the
|
|
81
|
+
// device changes, so a change that fails leaves it as it was.
|
|
82
|
+
const model = normalizeDevice(def);
|
|
83
|
+
const old = this._solver;
|
|
84
|
+
const solver = old ? new Solver(model) : null;
|
|
85
|
+
this.def = def;
|
|
86
|
+
this.model = model;
|
|
87
|
+
this._solver = solver;
|
|
88
|
+
if (old && old.u.length === solver.u.length && old.n === solver.n) {
|
|
89
|
+
solver.u.set(old.u);
|
|
90
|
+
solver.uLo.set(old.uLo);
|
|
91
|
+
solver.computeConcentrations();
|
|
92
|
+
solver.time = old.time;
|
|
93
|
+
solver.contactDEnd = old.contactDEnd;
|
|
94
|
+
solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
|
|
95
|
+
// Floating terminals keep their voltages (a good start), where the terminals match.
|
|
96
|
+
if (old.terms.length === solver.terms.length) {
|
|
97
|
+
for (const k of solver.floating) if (old.terms[k].name === solver.terms[k].name) solver.termV[k] = old.termV[k];
|
|
98
|
+
}
|
|
99
|
+
// What each stretch conserves from here: a stretch that was closed and still is (same
|
|
100
|
+
// species, same regions) keeps its amount; any other (newly closed, split, merged, open)
|
|
101
|
+
// starts from what the carried state holds.
|
|
102
|
+
const key = (st) => `${st.species}:${st.regions[0]}-${st.regions[1]}`;
|
|
103
|
+
const before = new Map(old.stretches.map((st, k) => [key(st), k]));
|
|
104
|
+
solver.referenceAmounts = solver.stretches.map((st) => {
|
|
105
|
+
const k = before.get(key(st));
|
|
106
|
+
return k !== undefined && !old.stretches[k].connected && !st.connected ? old.referenceAmounts[k] + old.boundaryIntake[k] : solver.amount(st);
|
|
107
|
+
});
|
|
108
|
+
solver.boundaryIntake.fill(0);
|
|
109
|
+
}
|
|
110
|
+
return this;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The definition def, which differs from the current one only in the terminals' drives: set
|
|
114
|
+
// them in place. (Validated before anything changes.)
|
|
115
|
+
_redefineDrives(def) {
|
|
116
|
+
const drives = normalizeDrives(def, this.model);
|
|
117
|
+
this.model.terminals.forEach((t, k) => (t.drive = drives[k]));
|
|
118
|
+
this.model.contacts.left.drive = drives[0];
|
|
119
|
+
this.model.contacts.right.drive = drives[1];
|
|
120
|
+
this.model.ports.forEach((p, k) => (p.drive = drives[2 + k]));
|
|
121
|
+
this.def = def;
|
|
122
|
+
this._solver.redrive();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Steady state (or equilibrium), warm-started from the current state. Doesn't advance time.
|
|
127
|
+
* @param {{ maxSteps?: number, tol?: number, continuation?: boolean }} [opts]
|
|
128
|
+
* @returns {Solution}
|
|
129
|
+
*/
|
|
130
|
+
solve(opts) {
|
|
131
|
+
return makeSolution(this.solver, this.solver.solveSteady(opts));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Advance the transient by dt (s) with backward Euler. If Newton fails, the interval is
|
|
136
|
+
* split into smaller steps automatically; `substeps` in the result says how many.
|
|
137
|
+
* With `{ method: 'bdf2' }`, steps after the first use second-order BDF2.
|
|
138
|
+
* @param {number} dt s
|
|
139
|
+
* @param {{ method?: 'be' | 'bdf2' }} [opts]
|
|
140
|
+
* @returns {Solution}
|
|
141
|
+
*/
|
|
142
|
+
step(dt, opts) {
|
|
143
|
+
return makeSolution(this.solver, this.solver.advance(dt, opts));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
|
|
148
|
+
* `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
|
|
149
|
+
* which), so an animation can call it once per frame; the step size carries over.
|
|
150
|
+
* @param {number} tEnd s
|
|
151
|
+
* @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be' }} [opts]
|
|
152
|
+
* @returns {Solution}
|
|
153
|
+
*/
|
|
154
|
+
advance(tEnd, opts) {
|
|
155
|
+
return makeSolution(this.solver, this.solver.integrate(tEnd, opts));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Small-signal impedance Z(f) about the steady state (solved first), at one terminal: its
|
|
160
|
+
* held voltage is perturbed (or its driven current), the others keep their drives.
|
|
161
|
+
* Z = δV/δI with I into the device (Ω·m²).
|
|
162
|
+
* @param {ArrayLike<number>} frequencies Hz
|
|
163
|
+
* @param {{ terminal?: string, profiles?: boolean }} [opts] the terminal (default 'right'), and
|
|
164
|
+
* whether to return complex profiles per frequency
|
|
165
|
+
* @returns {import('./types.js').ImpedanceResult}
|
|
166
|
+
*/
|
|
167
|
+
impedance(frequencies, opts) {
|
|
168
|
+
const steady = this.solver.solveSteady();
|
|
169
|
+
if (!steady.converged) throw new SolverError('impedance: the steady state did not converge');
|
|
170
|
+
return this.solver.impedance(frequencies, opts);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A checkpoint of the definition and solver state, for `_rollback()` (the live wrapper keeps
|
|
175
|
+
* its last good state this way).
|
|
176
|
+
* @internal
|
|
177
|
+
*/
|
|
178
|
+
_checkpoint() {
|
|
179
|
+
const solver = this.solver;
|
|
180
|
+
return { def: this.def, model: this.model, solver, snap: solver._snapshot(), solvedV: solver.solvedV, referenceAmounts: solver.referenceAmounts.slice() };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Back to a checkpoint taken on this device.
|
|
185
|
+
* @param {ReturnType<Device['_checkpoint']>} cp
|
|
186
|
+
* @internal
|
|
187
|
+
*/
|
|
188
|
+
_rollback(cp) {
|
|
189
|
+
if (this._solver === cp.solver) this._redefineDrives(cp.def); // only drives changed since: restore them exactly
|
|
190
|
+
else {
|
|
191
|
+
this.def = cp.def;
|
|
192
|
+
this.model = cp.model;
|
|
193
|
+
this._solver = cp.solver;
|
|
194
|
+
}
|
|
195
|
+
cp.solver._restore(cp.snap);
|
|
196
|
+
cp.solver.solvedV = cp.solvedV;
|
|
197
|
+
cp.solver.referenceAmounts = cp.referenceAmounts.slice();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Snapshot of the current state.
|
|
202
|
+
* @returns {Solution}
|
|
203
|
+
*/
|
|
204
|
+
solution() {
|
|
205
|
+
return makeSolution(this.solver);
|
|
206
|
+
}
|
|
207
|
+
}
|