driftlet 1.1.0 → 1.2.1
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 +1 -1
- package/docs/conventions.md +2 -2
- package/docs/data.md +9 -0
- package/docs/device.md +9 -6
- package/docs/numerics.md +23 -10
- package/docs/reliability.md +23 -3
- package/docs/statistics.md +1 -0
- package/llms.txt +6 -6
- package/package.json +2 -1
- package/src/channels.js +12 -7
- package/src/checks.js +6 -4
- package/src/data.js +5 -3
- package/src/describe.js +1 -1
- package/src/device.js +13 -8
- package/src/devices.js +9 -1
- package/src/difference.js +42 -7
- package/src/equation.js +14 -3
- package/src/index.js +6 -3
- package/src/junction.js +9 -3
- package/src/kit.js +1 -1
- package/src/live.js +14 -9
- package/src/particles.js +4 -1
- package/src/plot.js +8 -4
- package/src/solution.js +1 -0
- package/src/solver.js +329 -110
- package/src/stack.js +5 -1
- package/src/statistics.js +31 -10
- package/src/traces.js +10 -7
- package/src/types.js +96 -29
- package/types/data.d.ts +2 -1
- package/types/difference.d.ts +8 -2
- package/types/index.d.ts +14 -5
- package/types/kit.d.ts +1 -1
- package/types/plot.d.ts +2 -2
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +20 -3
- package/types/types.d.ts +179 -26
package/src/equation.js
CHANGED
|
@@ -79,14 +79,21 @@ export function stoichiometry(text, path = 'equation') {
|
|
|
79
79
|
*/
|
|
80
80
|
export function faceSides(terms, left, right, fixed, path) {
|
|
81
81
|
const sides = { left: {}, right: {} };
|
|
82
|
-
const put = (side, name, nu) => (sides[side][name] = (sides[side][name] ?? 0) + nu);
|
|
83
82
|
const fixedSide = right.conductor && !left.conductor ? 'right' : 'left';
|
|
83
|
+
const signs = {};
|
|
84
|
+
const put = (side, name, nu) => {
|
|
85
|
+
const key = `${side}:${name}`;
|
|
86
|
+
if (signs[key] !== undefined && signs[key] !== Math.sign(nu)) fail(`${path}: '${name}' (${side}) appears on both sides of the equation; give the net reaction`);
|
|
87
|
+
signs[key] = Math.sign(nu);
|
|
88
|
+
sides[side][name] = (sides[side][name] ?? 0) + nu;
|
|
89
|
+
};
|
|
84
90
|
for (const t of terms) {
|
|
85
|
-
|
|
91
|
+
// (a species the side holds is that species, given in fixed or not, as for bulk reactions)
|
|
92
|
+
const inL = left.holds(t.name), inR = right.holds(t.name);
|
|
93
|
+
if (!inL && !inR && fixed?.[t.name] !== undefined) {
|
|
86
94
|
put(fixedSide, t.name, t.nu);
|
|
87
95
|
continue;
|
|
88
96
|
}
|
|
89
|
-
const inL = left.holds(t.name), inR = right.holds(t.name);
|
|
90
97
|
if (inL && inR) {
|
|
91
98
|
fail(
|
|
92
99
|
`${path}: '${t.name}' is in both '${left.name}' and '${right.name}', so say which side: ` +
|
|
@@ -177,6 +184,10 @@ export function level(sol, halfReaction, { standard = false } = {}) {
|
|
|
177
184
|
else fail(`level: '${t.name}' is neither a species of the solution nor in the half-reaction's fixed`);
|
|
178
185
|
}
|
|
179
186
|
if (nuE === 0) fail(`level: ${JSON.stringify(equation)} has no net electrons`);
|
|
187
|
+
// (unbalanced, its level wouldn't move with φ as a level must)
|
|
188
|
+
const z = Object.fromEntries((sol.species ?? []).map((sp) => [sp.name, sp.z]));
|
|
189
|
+
const charge = terms.reduce((q, t) => q + t.nu * (t.name === 'e-' ? -1 : fixed[t.name] !== undefined ? 0 : (z[t.name] ?? NaN)), 0); // (fixed: neutral)
|
|
190
|
+
if (sol.species && !(Math.abs(charge) < 1e-12)) fail(`level: ${JSON.stringify(equation)} isn't charge-balanced (Σ ν z = ${charge} across it)`);
|
|
180
191
|
const n = sol.mu[Object.keys(sol.mu)[0]].length;
|
|
181
192
|
const V = new Float64Array(n);
|
|
182
193
|
for (let g = 0; g < n; g++) {
|
package/src/index.js
CHANGED
|
@@ -211,11 +211,14 @@ export class Device {
|
|
|
211
211
|
* Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
|
|
212
212
|
* `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
|
|
213
213
|
* which), so an animation can call it once per frame; the step size carries over. `probes`
|
|
214
|
-
* read a species at points inside the device after every accepted step, into
|
|
215
|
-
* (one array per probe, alongside `trace.t`): what a detector at x sees.
|
|
214
|
+
* read a species (or φ) at points inside the device after every accepted step, into
|
|
215
|
+
* `trace.probes` (one array per probe, alongside `trace.t`): what a detector at x sees. At a
|
|
216
|
+
* face, `{ interface, species }` reads a species' flux through it (mol/(m²·s), toward +x), and
|
|
217
|
+
* `{ interface, gate }` a gate's fraction open.
|
|
216
218
|
* @param {number} tEnd s
|
|
217
219
|
* @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
|
|
218
|
-
* probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }
|
|
220
|
+
* probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }
|
|
221
|
+
* | { interface: number, species: string } | { interface: number, gate: string })[] }} [opts]
|
|
219
222
|
* @returns {Solution}
|
|
220
223
|
*/
|
|
221
224
|
advance(tEnd, opts) {
|
package/src/junction.js
CHANGED
|
@@ -29,6 +29,9 @@ function neutral(list, side, what) {
|
|
|
29
29
|
}
|
|
30
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
31
|
}
|
|
32
|
+
function holds(list, what) {
|
|
33
|
+
for (const side of ['cL', 'cR']) if (!list.some((p) => p[side] > 0)) fail(`${what}: the ${side === 'cL' ? 'left' : 'right'} solution holds no ions`);
|
|
34
|
+
}
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Henderson's liquid-junction potential, φ_R − φ_L (V): the junction taken to be the two solutions
|
|
@@ -44,6 +47,7 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
|
|
|
44
47
|
const list = prepare(left, right, ions, 'henderson');
|
|
45
48
|
neutral(list, 'cL', 'henderson');
|
|
46
49
|
neutral(list, 'cR', 'henderson');
|
|
50
|
+
holds(list, 'henderson');
|
|
47
51
|
let a = 0, b = 0, gL = 0, gR = 0;
|
|
48
52
|
for (const { z, D, cL, cR } of list) {
|
|
49
53
|
a += z * D * (cR - cL);
|
|
@@ -51,9 +55,10 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
|
|
|
51
55
|
gL += z * z * D * cL;
|
|
52
56
|
gR += z * z * D * cR;
|
|
53
57
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
// ln(g_R/g_L)/(g_R − g_L) = ln(1 + r)/(r g_L), r = b/g_L: 1/g_L at equal conductivities, where the
|
|
59
|
+
// plain form is 0/0.
|
|
60
|
+
const VT = (GAS_CONSTANT * T) / FARADAY, r = b / gL;
|
|
61
|
+
return (-VT * a * (Math.abs(r) < 1e-8 ? 1 - r / 2 : Math.log1p(r) / r)) / gL;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/**
|
|
@@ -71,6 +76,7 @@ export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
|
|
|
71
76
|
const list = prepare(left, right, ions, 'planck');
|
|
72
77
|
neutral(list, 'cL', 'planck');
|
|
73
78
|
neutral(list, 'cR', 'planck');
|
|
79
|
+
holds(list, 'planck');
|
|
74
80
|
const n = list.length, z = list.map((p) => p.z), D = list.map((p) => p.D), Dmax = Math.max(...D);
|
|
75
81
|
const scale = Math.max(...list.map((p) => Math.max(p.cL, p.cR)));
|
|
76
82
|
const cL = list.map((p) => p.cL / scale), cR = list.map((p) => p.cR / scale);
|
package/src/kit.js
CHANGED
|
@@ -71,7 +71,7 @@ export function vacuumDipole(def, left, right) {
|
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* The vacuum-level (Schottky–Mott-type) estimate of a `zeroCharge` where a conductor meets a
|
|
74
|
-
* material: at a conductor region's face, or a contact's capacitive or
|
|
74
|
+
* material: at a conductor region's face, or a contact's capacitive or pinned law. The conductor's
|
|
75
75
|
* vacuum level sits its work function W beyond its Fermi level, so
|
|
76
76
|
* zeroCharge = V_F − φ_edge at zero charge = W + (V_vac − φ)_inside.
|
|
77
77
|
* @param {object} def device definition
|
package/src/live.js
CHANGED
|
@@ -57,26 +57,31 @@ export class Session {
|
|
|
57
57
|
if (this.good && errors.every(Boolean)) return done(this.good.sol, { failed: true }); // nothing changed
|
|
58
58
|
const target = this.device.def;
|
|
59
59
|
try {
|
|
60
|
-
let sol = this.device.solve();
|
|
61
|
-
// Ramp from the last good state in more and more steps.
|
|
62
|
-
|
|
60
|
+
let sol = this.device.solve(), stuck = false;
|
|
61
|
+
// Ramp from the last good state in more and more steps. (Its in-between states can be
|
|
62
|
+
// invalid where the two ends aren't, a bath neutral at both but not between: then on to the
|
|
63
|
+
// fresh solve.)
|
|
64
|
+
ramping: for (let n = 2; !sol.converged && this.good && n <= this.maxRamp; n *= 2) {
|
|
63
65
|
ramp = n;
|
|
64
66
|
this.device._rollback(this.good.cp);
|
|
65
67
|
const from = this.good.cp.def;
|
|
66
68
|
for (let k = 1; k <= n; k++) {
|
|
67
|
-
|
|
69
|
+
try {
|
|
70
|
+
this.device.set(between(from, target, k / n));
|
|
71
|
+
} catch {
|
|
72
|
+
stuck = true; // (and sol, a converged step short of the target, isn't the answer)
|
|
73
|
+
break ramping;
|
|
74
|
+
}
|
|
68
75
|
sol = this.device.solve();
|
|
69
76
|
if (!sol.converged) break;
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
|
-
if (!sol.converged) {
|
|
79
|
+
if (stuck || !sol.converged) {
|
|
73
80
|
// Last, from scratch (with the solver's own continuations).
|
|
74
81
|
const fresh = new Device(target);
|
|
75
82
|
const cold = fresh.solve();
|
|
76
|
-
if (cold.converged)
|
|
77
|
-
|
|
78
|
-
sol = cold;
|
|
79
|
-
}
|
|
83
|
+
if (cold.converged) this.device = fresh;
|
|
84
|
+
if (cold.converged || stuck) sol = cold;
|
|
80
85
|
}
|
|
81
86
|
if (sol.converged) {
|
|
82
87
|
this.good = { cp: this.device._checkpoint(), sol };
|
package/src/particles.js
CHANGED
|
@@ -55,6 +55,8 @@ const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea
|
|
|
55
55
|
*/
|
|
56
56
|
export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
|
|
57
57
|
checkOptions('particles', arguments[1], ['species', 'dots', 'weight', 'cap', 'cells', 'random']);
|
|
58
|
+
// (amounts per area, against fluxes that off a plane are per device: planar only)
|
|
59
|
+
if (sol.geometry !== undefined && sol.geometry !== 'planar') throw new RangeError(`particles: a planar device's solution, not a ${sol.geometry} one`);
|
|
58
60
|
const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
|
|
59
61
|
for (const nm of names) if (!sol.c[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in the solution`);
|
|
60
62
|
if (!(dots > 0 && Number.isFinite(dots))) throw new RangeError(`particles: dots must be a positive number, got ${dots}`);
|
|
@@ -172,7 +174,8 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
|
|
|
172
174
|
}
|
|
173
175
|
[left[k], right[k]] = [side(k - 1), side(k)];
|
|
174
176
|
const hD = A.h / 2 / D[A.mid] + B.h / 2 / D[B.mid];
|
|
175
|
-
|
|
177
|
+
// (none of it where either cell is empty: an underflow to 0 makes both ψ infinite)
|
|
178
|
+
pair(k, J[A.b], N[k - 1] > 0 && N[k] > 0 ? (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD : 0);
|
|
176
179
|
} else if (A || B) {
|
|
177
180
|
// out of the cells: through a contact, or a face to a metal
|
|
178
181
|
const cl = A ?? B, g = A ? A.b : B.a;
|
package/src/plot.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { traces } from './traces.js';
|
|
16
16
|
import { powi } from './pow.js';
|
|
17
|
-
import { checkOptions } from './errors.js';
|
|
17
|
+
import { DeviceError, checkOptions } from './errors.js';
|
|
18
18
|
|
|
19
19
|
// Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
|
|
20
20
|
// dash and the legend carry identity too). A role with more members than colours cycles.
|
|
@@ -23,16 +23,20 @@ export const THEME = Object.freeze({
|
|
|
23
23
|
dark: { electron: ['#5b95d6'], cation: ['#e8584f', '#d0772a', '#d65d95', '#b88a3e'], anion: ['#219c8f', '#2aa5bb', '#3aa874'], redox: ['#8b7ff0', '#a86bc9', '#7a9ae0'] },
|
|
24
24
|
});
|
|
25
25
|
const ROLES = ['electron', 'cation', 'anion', 'redox'];
|
|
26
|
+
const MUTED = { light: '#52514e', dark: '#c3c2b7' }; // (φ's, and lines of no species)
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The colour of a trace's role and slot (a CSS colour), for drawing other charts in the same
|
|
29
30
|
* colours as the level diagrams.
|
|
30
|
-
* @param {'electron' | 'cation' | 'anion' | 'redox'} role
|
|
31
|
+
* @param {'electron' | 'cation' | 'anion' | 'redox' | 'phi'} role
|
|
31
32
|
* @param {number} slot
|
|
32
33
|
* @param {{ dark?: boolean }} [opts]
|
|
33
34
|
*/
|
|
34
35
|
export function themeColor(role, slot, { dark = false } = {}) {
|
|
35
36
|
checkOptions('themeColor', arguments[2], ['dark']);
|
|
37
|
+
if (role === 'phi') return MUTED[dark ? 'dark' : 'light'];
|
|
38
|
+
if (!ROLES.includes(role)) throw new DeviceError(`themeColor: no role '${role}' (${[...ROLES, 'phi'].join(', ')})`);
|
|
39
|
+
if (!(Number.isInteger(slot) && slot >= 0)) throw new DeviceError(`themeColor: slot must be a whole number ≥ 0, got ${slot}`);
|
|
36
40
|
const list = THEME[dark ? 'dark' : 'light'][role];
|
|
37
41
|
return list[slot % list.length];
|
|
38
42
|
}
|
|
@@ -118,8 +122,8 @@ function lengthUnit(L) {
|
|
|
118
122
|
|
|
119
123
|
const style = (id) => {
|
|
120
124
|
const vars = (mode) => ROLES.flatMap((role) => THEME[mode][role].map((c, k) => `--driftlet-${role}${role === 'electron' ? '' : `-${k + 1}`}:${c};`)).join('');
|
|
121
|
-
const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted
|
|
122
|
-
const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted
|
|
125
|
+
const light = `${vars('light')}--driftlet-ink:#0b0b0b;--driftlet-muted:${MUTED.light};--driftlet-grid:#e4e3df;--driftlet-band:#f1f0ec;--driftlet-face:#a8a7a1`;
|
|
126
|
+
const dark = `${vars('dark')}--driftlet-ink:#ffffff;--driftlet-muted:${MUTED.dark};--driftlet-grid:#3a3a37;--driftlet-band:#262624;--driftlet-face:#6b6a64`;
|
|
123
127
|
return (
|
|
124
128
|
`<style>` +
|
|
125
129
|
`:where(#${id}){${light}}` +
|
package/src/solution.js
CHANGED
|
@@ -27,6 +27,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
27
27
|
V: {},
|
|
28
28
|
Vstd: {},
|
|
29
29
|
T: model.T,
|
|
30
|
+
geometry: model.geometry.type, // (planar, or amounts per device rather than per area)
|
|
30
31
|
time: solver.time,
|
|
31
32
|
steady: solver.atSteady,
|
|
32
33
|
converged: result.converged ?? true,
|