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.
Files changed (47) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +285 -0
  3. package/llms.txt +299 -0
  4. package/package.json +54 -0
  5. package/src/bernoulli.js +29 -0
  6. package/src/blockTridiagonal.js +492 -0
  7. package/src/constants.js +36 -0
  8. package/src/data.js +127 -0
  9. package/src/describe.js +190 -0
  10. package/src/device.js +954 -0
  11. package/src/equation.js +188 -0
  12. package/src/errors.js +8 -0
  13. package/src/fermi.js +106 -0
  14. package/src/grid.js +111 -0
  15. package/src/index.js +207 -0
  16. package/src/kit.js +76 -0
  17. package/src/live.js +217 -0
  18. package/src/merge.js +11 -0
  19. package/src/plot.js +267 -0
  20. package/src/solution.js +246 -0
  21. package/src/solver.js +2884 -0
  22. package/src/stack.js +172 -0
  23. package/src/statistics.js +593 -0
  24. package/src/traces.js +125 -0
  25. package/src/types.js +221 -0
  26. package/src/worker.js +24 -0
  27. package/types/bernoulli.d.ts +4 -0
  28. package/types/blockTridiagonal.d.ts +92 -0
  29. package/types/constants.d.ts +33 -0
  30. package/types/data.d.ts +73 -0
  31. package/types/describe.d.ts +15 -0
  32. package/types/device.d.ts +20 -0
  33. package/types/equation.d.ts +102 -0
  34. package/types/errors.d.ts +3 -0
  35. package/types/fermi.d.ts +3 -0
  36. package/types/grid.d.ts +28 -0
  37. package/types/index.d.ts +103 -0
  38. package/types/kit.d.ts +61 -0
  39. package/types/live.d.ts +101 -0
  40. package/types/merge.d.ts +1 -0
  41. package/types/plot.d.ts +60 -0
  42. package/types/solution.d.ts +18 -0
  43. package/types/solver.d.ts +342 -0
  44. package/types/stack.d.ts +59 -0
  45. package/types/statistics.d.ts +10 -0
  46. package/types/traces.d.ts +118 -0
  47. package/types/types.d.ts +574 -0
@@ -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
@@ -0,0 +1,8 @@
1
+ // The error a definition's validation throws, naming the offending path.
2
+
3
+ export class DeviceError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'DeviceError';
7
+ }
8
+ }
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
+ }