driftlet 0.16.4 → 1.0.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.
Files changed (49) hide show
  1. package/README.md +3 -3
  2. package/docs/device.md +28 -14
  3. package/docs/kit.md +3 -1
  4. package/docs/numerics.md +12 -0
  5. package/docs/statistics.md +2 -1
  6. package/docs/templates.md +260 -0
  7. package/llms.txt +106 -300
  8. package/package.json +15 -3
  9. package/src/channels.js +6 -4
  10. package/src/checks.js +7 -2
  11. package/src/data.js +5 -1
  12. package/src/device.js +10 -4
  13. package/src/devices.js +2 -0
  14. package/src/equation.js +2 -1
  15. package/src/errors.js +33 -0
  16. package/src/grid.js +12 -0
  17. package/src/index.js +17 -7
  18. package/src/junction.js +3 -1
  19. package/src/kit.js +1 -1
  20. package/src/live.js +2 -0
  21. package/src/particles.js +2 -0
  22. package/src/plot.js +4 -0
  23. package/src/polarization.js +4 -3
  24. package/src/recorder.js +2 -1
  25. package/src/solution.js +20 -5
  26. package/src/solver.js +70 -16
  27. package/src/sources.js +8 -1
  28. package/src/stack.js +14 -1
  29. package/src/statistics.js +1 -1
  30. package/src/traces.js +3 -0
  31. package/src/types.js +5 -2
  32. package/types/channels.d.ts +3 -3
  33. package/types/checks.d.ts +1 -1
  34. package/types/data.d.ts +3 -3
  35. package/types/devices.d.ts +1 -1
  36. package/types/equation.d.ts +1 -1
  37. package/types/errors.d.ts +8 -0
  38. package/types/index.d.ts +2 -5
  39. package/types/junction.d.ts +2 -2
  40. package/types/kit.d.ts +1 -1
  41. package/types/live.d.ts +1 -1
  42. package/types/particles.d.ts +1 -1
  43. package/types/plot.d.ts +3 -3
  44. package/types/polarization.d.ts +1 -1
  45. package/types/recorder.d.ts +1 -1
  46. package/types/solver.d.ts +2 -0
  47. package/types/sources.d.ts +7 -7
  48. package/types/traces.d.ts +1 -1
  49. package/types/types.d.ts +11 -2
package/src/checks.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { Device } from './index.js';
6
6
  import { FARADAY } from './constants.js';
7
7
  import { unitWarnings } from './describe.js';
8
+ import { checkOptions } from './errors.js';
8
9
 
9
10
  const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs(v) >= 1e4 || Math.abs(v) < 1e-2 ? v.toExponential(p - 1) : String(+v.toPrecision(p)));
10
11
 
@@ -48,6 +49,7 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
48
49
  * @returns {{ ok: boolean, items: CheckItem[], text: string }}
49
50
  */
50
51
  export function check(device, sol, { refine = true, tol = 1e-2, order = true } = {}) {
52
+ checkOptions('check', arguments[2], ['refine', 'tol', 'order']);
51
53
  sol ??= device.solution();
52
54
  const { model, solver, def } = device;
53
55
  const steady = sol.steady ?? solver.atSteady;
@@ -170,7 +172,10 @@ function balance(device, sol) {
170
172
  const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
171
173
  const natural = naturalScales(device, sol), inSolution = naturalScales(device, sol, false); // (partners' scale: not a metal's conductance)
172
174
  const scale = (l) => Math.max(natural[l.i], ...l.terms.flatMap((t) => (partners.get(t.what) ?? []).map((j) => inSolution[j])));
173
- const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l));
175
+ // Small flows count too where they balance, as real ones do to round-off (a diode at 1 µV,
176
+ // 1e-19 of its scale, to 1e-15), and round-off doesn't. (Above 1e-8 of the scale, any.)
177
+ const balanced = (l) => Math.abs(l.terms.reduce((a, t) => a + t.rate, 0)) <= 1e-9 * size(l);
178
+ const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l) || (size(l) > 1e-20 * scale(l) && balanced(l)));
174
179
  let worst = 0;
175
180
  const lines = [];
176
181
  for (const l of moving) {
@@ -379,7 +384,7 @@ function orderCheck(device, sol, gross) {
379
384
  ? 'the same steady state with the species listed in reverse'
380
385
  : ok === null
381
386
  ? `listed in reverse, the species give nearly the same steady state (${what}): not to round-off, so some weakly held part is not quite steady in one of them`
382
- : `listed in reverse, the species give a different steady state: ${what}. One of them is not steady, though its solve converged: compare with a long advance(), and please report the device.`,
387
+ : `listed in reverse, the species give a different steady state: ${what}. One of them is not steady, though its solve converged, or the device has more than one steady state (one of them perhaps unstable): compare with a long advance() from the start, kick each and watch it with dtMax (see numerics.md, Steady state), and please report the device.`,
383
388
  details: { diffs },
384
389
  };
385
390
  }
package/src/data.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // only. How levels line up at an interface, and a surface's work function, belong to that
4
4
  // interface or surface and are never in here.
5
5
 
6
- import { DeviceError } from './errors.js';
6
+ import { DeviceError, checkOptions } from './errors.js';
7
7
  import { FARADAY, GAS_CONSTANT, AVOGADRO } from './constants.js';
8
8
 
9
9
  const fail = (message) => {
@@ -72,6 +72,7 @@ export const METALS = freeze(
72
72
  * @returns {{ species: object[], materials: Record<string, object> }}
73
73
  */
74
74
  export function aqueous(names, { epsr = WATER_EPSR, material = 'water' } = {}) {
75
+ checkOptions('aqueous', arguments[1], ['epsr', 'material']);
75
76
  if (!Array.isArray(names) || names.length === 0) fail('aqueous: give a list of ions, e.g. aqueous([\'Na+\', \'Cl-\'])');
76
77
  const species = [], entries = {};
77
78
  for (const name of names) {
@@ -93,6 +94,8 @@ export function aqueous(names, { epsr = WATER_EPSR, material = 'water' } = {}) {
93
94
  * @returns {{ species: object[], materials: Record<string, object> }}
94
95
  */
95
96
  export function semiconductor(name, { material = name } = {}) {
97
+ if (arguments[1]?.T !== undefined) fail('semiconductor: the data are for 300 K and take no T: give the device T: 300, or write your own material for another temperature (SEMICONDUCTORS has the numbers)');
98
+ checkOptions('semiconductor', arguments[1], ['material']);
96
99
  const sc = SEMICONDUCTORS[name];
97
100
  if (!sc) fail(`semiconductor: no data for '${name}' (the library has ${Object.keys(SEMICONDUCTORS).join(', ')})`);
98
101
  const VT = (GAS_CONSTANT * 300) / FARADAY;
@@ -121,6 +124,7 @@ export function semiconductor(name, { material = name } = {}) {
121
124
  * @returns {{ species: object[], materials: Record<string, object> }}
122
125
  */
123
126
  export function metal(name, { material = name } = {}) {
127
+ checkOptions('metal', arguments[1], ['material']);
124
128
  const m = METALS[name];
125
129
  if (!m) fail(`metal: no data for '${name}' (the library has ${Object.keys(METALS).join(', ')})`);
126
130
  return { species: [{ name: 'e-', z: -1 }], materials: { [material]: { conductor: { species: 'e-', conductivity: m.conductivity } } } };
package/src/device.js CHANGED
@@ -355,8 +355,8 @@ export function normalizeDevice(def) {
355
355
  // A contact that passes nothing can't be driven by a current.
356
356
  for (const side of ['left', 'right']) {
357
357
  const ct = contacts[side];
358
- const passes = ct.species.some((l) => l.type !== 'blocked') || ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
359
- need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked species, no gate)`);
358
+ const passes = ct.species.some((l, i) => l.type !== 'blocked' && species[i].z !== 0) || ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
359
+ need(passes || ct.drive.kind === 'V', `contacts.${side}.I: this contact passes no current (no linked charged species, no gate)`);
360
360
  ct.passes = passes;
361
361
  }
362
362
  checkLevel(terminals.map((t) => t.drive), terminals.map((t) => (t.kind === 'port' ? ports[t.index] : contacts[t.side])));
@@ -889,7 +889,13 @@ function portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, sur
889
889
  need(part.some((p) => p.side === 0 || p.side === 2), `${rpath}: no species of the port's region or its surface takes part`);
890
890
  need(charge === 0, `${rpath}: charge is not balanced (Σ ν z = ${charge})`);
891
891
  need(rdef.bare === undefined || typeof rdef.bare === 'boolean', `${rpath}.bare must be true or false`);
892
- return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha: transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare: rdef.bare === true };
892
+ const alpha = transferCoefficient(rdef.alpha, `${rpath}.alpha`), bare = rdef.bare === true;
893
+ // Langmuir sites: the reaction fills Δ of them going forward, and its transition state holds
894
+ // m bare ones (those it fills, or one if it runs only on bare metal), so the forward rate
895
+ // goes as θ₀^m and the backward as θ₀^(m−Δ): the prefactor's θ₀^(m(1−α)+(m−Δ)α), with each
896
+ // surface species at its coverage θ.
897
+ const fills = part.reduce((t, p) => t + (p.side === 2 ? p.nu : 0), 0), m = Math.max(fills, 0, bare ? 1 : 0);
898
+ return { part, fixedA, k0: positive(rdef.k0, `${rpath}.k0 (mol/(m²·s))`), alpha, bare, sites: m * (1 - alpha) + (m - fills) * alpha };
893
899
  });
894
900
  }
895
901
 
@@ -1316,7 +1322,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1316
1322
  fixed !== 0
1317
1323
  ? `${path}.bath: the bath is the end material '${mat.name}' at this composition, and with its fixed charge (${fixed} mol/m³ of charge) it is not neutral (net ${charge}). ` +
1318
1324
  `For a solution outside a charged region, give the solution's material (bath.material), whose composition is then neutral on its own`
1319
- : `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`,
1325
+ : `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge): add the counter-ions' share`,
1320
1326
  );
1321
1327
  // The reference species pins the bath's φ; every other species follows from composition.
1322
1328
  const zb = bathZeta(bm, cb, own ? 0 : region.background);
package/src/devices.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { AVOGADRO, FARADAY, GAS_CONSTANT, units } from './constants.js';
6
6
  import { photogeneration } from './sources.js';
7
7
  import { drive } from './stack.js';
8
+ import { checkOptions } from './errors.js';
8
9
 
9
10
  const m3 = (x) => x / AVOGADRO, eV = units.eV;
10
11
 
@@ -33,6 +34,7 @@ const P = {
33
34
  * @returns {import('./types.js').DeviceDefinition}
34
35
  */
35
36
  export function perovskiteCell({ V = 1.2, light = 1, bulkSRH = true } = {}) {
37
+ checkOptions('perovskiteCell', arguments[0], ['V', 'light', 'bulkSRH']);
36
38
  const { T } = P, VT = (GAS_CONSTANT * T) / FARADAY;
37
39
  const ni = Math.sqrt(P.gc * P.gv) * Math.exp(-(P.Ec - P.Ev) / (2 * VT));
38
40
  const n0 = P.gc * Math.exp((P.EcE + VT * Math.log(P.dE / P.gcE) - P.Ec) / VT); // the perovskite's n at the TiO₂'s Fermi level
package/src/equation.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // definition's reactions can be written this way; they compile to signed stoichiometry (ν < 0
3
3
  // consumed by the forward reaction, left to right as written).
4
4
 
5
- import { DeviceError } from './errors.js';
5
+ import { DeviceError, checkOptions } from './errors.js';
6
6
  import { FARADAY } from './constants.js';
7
7
 
8
8
  const fail = (message) => {
@@ -163,6 +163,7 @@ export const SHE = Object.freeze(half('2 H+ + 2 e- = H2', { H2: 0 }));
163
163
  * @returns {Float64Array}
164
164
  */
165
165
  export function level(sol, halfReaction, { standard = false } = {}) {
166
+ checkOptions('level', arguments[2], ['standard']);
166
167
  if (!isObject(halfReaction)) fail('level: give a half-reaction from half()');
167
168
  const { equation, fixed = {} } = halfReaction;
168
169
  const terms = parseEquation(equation, 'level');
package/src/errors.js CHANGED
@@ -6,3 +6,36 @@ export class DeviceError extends Error {
6
6
  this.name = 'DeviceError';
7
7
  }
8
8
  }
9
+
10
+ // The nearest of `names` to a mistyped one: the same but for case, '_' or '-', or one or two
11
+ // letters off (dtmax for dtMax, engery for energy).
12
+ function nearest(name, names) {
13
+ const plain = (s) => s.toLowerCase().replace(/[_-]/g, '');
14
+ const edits = (a, b) => {
15
+ let row = Array.from({ length: b.length + 1 }, (_, j) => j);
16
+ for (let i = 1; i <= a.length; i++) {
17
+ const next = [i];
18
+ for (let j = 1; j <= b.length; j++) next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
19
+ row = next;
20
+ }
21
+ return row[b.length];
22
+ };
23
+ return names.find((n) => plain(n) === plain(name)) ?? names.find((n) => n.length > 3 && edits(plain(n), plain(name)) <= 2);
24
+ }
25
+
26
+ /**
27
+ * A function's options: a name it doesn't take (a typo) is an error, not silently ignored.
28
+ * @param {string} where the function, for the message
29
+ * @param {unknown} opts
30
+ * @param {string[]} allowed
31
+ * @param {new (message: string) => Error} [Kind]
32
+ */
33
+ export function checkOptions(where, opts, allowed, Kind = DeviceError) {
34
+ if (opts === undefined || opts === null) return;
35
+ if (typeof opts !== 'object' || Array.isArray(opts)) throw new Kind(`${where}: options must be an object (${allowed.join(', ')})`);
36
+ for (const k of Object.keys(opts)) {
37
+ if (opts[k] === undefined || allowed.includes(k)) continue;
38
+ const near = nearest(k, allowed);
39
+ throw new Kind(`${where}: no option '${k}'${near ? ` (did you mean '${near}'?)` : ''}; the options are ${allowed.join(', ')}`);
40
+ }
41
+ }
package/src/grid.js CHANGED
@@ -10,6 +10,7 @@
10
10
  // cell widths outright (`cells`), as a metal region does: one cell, exact for Ohm's law.
11
11
 
12
12
  const DEFAULTS = Object.freeze({ ratio: 1.2, minCells: 8 });
13
+ const MAX_CELLS = 1e6; // a live demo's grid has hundreds to thousands
13
14
 
14
15
  /**
15
16
  * Cell widths for one region: fine at both ends, growing toward the middle.
@@ -52,6 +53,17 @@ export function gradedCells(length, { hmin, hmax, ratio = DEFAULTS.ratio, minCel
52
53
  */
53
54
  export function buildGrid(regions, opts = {}) {
54
55
  if (!Array.isArray(regions) || regions.length === 0) throw new Error('grid needs at least one region');
56
+ // A grid far past any demo's (most often a length given in µm as if in m) would only exhaust
57
+ // memory: refuse it before building it. Each region needs at least length/hmax cells.
58
+ const least = regions.map((reg) => {
59
+ const g = { ...DEFAULTS, ...opts, ...(reg.grid ?? {}) };
60
+ return reg.cells ? reg.cells.length : reg.length / Math.min(g.hmax ?? Infinity, reg.length / g.minCells);
61
+ });
62
+ const total = least.reduce((s, n) => s + n, 0);
63
+ if (!(total <= MAX_CELLS)) {
64
+ const r = least.indexOf(Math.max(...least)), g = { ...opts, ...(regions[r].grid ?? {}) };
65
+ throw new RangeError(`at least ${total.toExponential(1)} cells, past the ${MAX_CELLS.toExponential(0)} a device may have: ${regions[r].name ?? `region ${r}`} is ${regions[r].length} m long at hmax ${g.hmax ?? '(none)'} m. Lengths are in metres (units.um(1) is 1e-6): check them, or raise hmax`);
66
+ }
55
67
 
56
68
  const perRegion = regions.map((reg) => reg.cells ?? gradedCells(reg.length, { ...opts, ...(reg.grid ?? {}) }));
57
69
  const nNodes = perRegion.reduce((s, cells) => s + cells.length + 1, 0);
package/src/index.js CHANGED
@@ -4,13 +4,11 @@ import { normalizeDevice, DeviceError } from './device.js';
4
4
  import { Solver, SolverError } from './solver.js';
5
5
  import { makeSolution } from './solution.js';
6
6
  import { merge } from './merge.js';
7
+ import { checkOptions } from './errors.js';
7
8
 
8
- export { DeviceError, normalizeDevice } from './device.js';
9
+ export { DeviceError } from './device.js';
9
10
  import { normalizeDrives } from './device.js';
10
11
  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
12
  export * from './constants.js';
15
13
 
16
14
  // Whether two definitions differ only in their terminals' drives (V, I, R on the contacts and
@@ -32,7 +30,10 @@ function sameExceptDrives(a, b) {
32
30
  for (const k of keys) {
33
31
  if (k === 'contacts') {
34
32
  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;
33
+ for (const side of new Set([...Object.keys(ca), ...Object.keys(cb)])) {
34
+ if (side !== 'left' && side !== 'right') return false; // a misspelt side: rebuild, which says so
35
+ if (!eq(ca[side] ?? {}, cb[side] ?? {}, true)) return false;
36
+ }
36
37
  } else if (k === 'ports') {
37
38
  const pa = a.ports ?? [], pb = b.ports ?? [];
38
39
  if (pa.length !== pb.length || pa.some((p, i) => !eq(p, pb[i], true))) return false;
@@ -41,6 +42,9 @@ function sameExceptDrives(a, b) {
41
42
  return true;
42
43
  }
43
44
 
45
+ // A method's options: a name it doesn't take (a typo, dtmax for dtMax) is an error, not ignored.
46
+ const options = (opts, method, allowed) => checkOptions(method, opts, allowed, SolverError);
47
+
44
48
  /** @typedef {import('./types.js').DeviceDefinition} DeviceDefinition */
45
49
  /** @typedef {import('./types.js').Solution} Solution */
46
50
 
@@ -179,6 +183,7 @@ export class Device {
179
183
  * @returns {Solution}
180
184
  */
181
185
  solve(opts) {
186
+ options(opts, 'solve', ['maxSteps', 'tol', 'continuation']);
182
187
  // A solve that fails reports the state it failed in, and leaves the device as it was: a
183
188
  // device with no steady state (a host filling at a current) can still advance() from there.
184
189
  const snap = this.solver._snapshot();
@@ -196,6 +201,7 @@ export class Device {
196
201
  * @returns {Solution}
197
202
  */
198
203
  step(dt, opts) {
204
+ options(opts, 'step', ['method']);
199
205
  return makeSolution(this.solver, this.solver.advance(dt, opts));
200
206
  }
201
207
 
@@ -211,22 +217,26 @@ export class Device {
211
217
  * @returns {Solution}
212
218
  */
213
219
  advance(tEnd, opts) {
220
+ options(opts, 'advance', ['tol', 'dt0', 'dtMax', 'budgetMs', 'maxSteps', 'method', 'probes']);
214
221
  return makeSolution(this.solver, this.solver.integrate(tEnd, opts));
215
222
  }
216
223
 
217
224
  /**
218
225
  * Small-signal impedance Z(f) about the steady state (solved first), at one terminal: its
219
226
  * held voltage is perturbed (or its driven current), the others keep their drives.
220
- * Z = δV/δI with I into the device (Ω·m²).
227
+ * Z = δV/δI with I into the device (Ω·m²), with the steady state's `warnings`.
221
228
  * @param {ArrayLike<number>} frequencies Hz
222
229
  * @param {{ terminal?: string, profiles?: boolean }} [opts] the terminal (default 'right'), and
223
230
  * whether to return complex profiles per frequency
224
231
  * @returns {import('./types.js').ImpedanceResult}
225
232
  */
226
233
  impedance(frequencies, opts) {
234
+ options(opts, 'impedance', ['terminal', 'profiles']);
227
235
  const steady = this.solver.solveSteady();
228
236
  if (!steady.converged) throw new SolverError('impedance: the steady state did not converge');
229
- return this.solver.impedance(frequencies, opts);
237
+ // The steady state's warnings (an unresolved double layer, say) hold for the impedance too.
238
+ const { warnings } = makeSolution(this.solver, steady);
239
+ return { ...this.solver.impedance(frequencies, opts), warnings };
230
240
  }
231
241
 
232
242
  /**
package/src/junction.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // junction (steady between two baths, it is Planck's; stepping from a sharp boundary, a free
4
4
  // diffusion junction); these are for comparison, and for quick estimates.
5
5
 
6
- import { DeviceError } from './errors.js';
6
+ import { DeviceError, checkOptions } from './errors.js';
7
7
  import { FARADAY, GAS_CONSTANT } from './constants.js';
8
8
 
9
9
  const fail = (message) => {
@@ -40,6 +40,7 @@ function neutral(list, side, what) {
40
40
  * @param {{ T?: number }} [opts] K (default 298.15)
41
41
  */
42
42
  export function henderson(left, right, ions, { T = 298.15 } = {}) {
43
+ checkOptions('henderson', arguments[3], ['T']);
43
44
  const list = prepare(left, right, ions, 'henderson');
44
45
  neutral(list, 'cL', 'henderson');
45
46
  neutral(list, 'cR', 'henderson');
@@ -66,6 +67,7 @@ export function henderson(left, right, ions, { T = 298.15 } = {}) {
66
67
  * @param {{ T?: number, steps?: number }} [opts] K (default 298.15); RK4 steps across (default 4000)
67
68
  */
68
69
  export function planck(left, right, ions, { T = 298.15, steps = 4000 } = {}) {
70
+ checkOptions('planck', arguments[3], ['T', 'steps']);
69
71
  const list = prepare(left, right, ions, 'planck');
70
72
  neutral(list, 'cL', 'planck');
71
73
  neutral(list, 'cR', 'planck');
package/src/kit.js CHANGED
@@ -6,7 +6,7 @@ import { DeviceError } from './errors.js';
6
6
  import { FARADAY } from './constants.js';
7
7
 
8
8
  export { build, combine, layer, ohmic, bath } from './stack.js';
9
- export { parseEquation, stoichiometry, half, SHE, level } from './equation.js';
9
+ export { half, SHE, level } from './equation.js';
10
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';
package/src/live.js CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { Device } from './index.js';
8
8
  import { merge } from './merge.js';
9
+ import { checkOptions } from './errors.js';
9
10
 
10
11
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && !ArrayBuffer.isView(v);
11
12
  const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
@@ -118,6 +119,7 @@ export class Session {
118
119
  * @returns {Live}
119
120
  */
120
121
  export function live(def, { worker = false, onsolution, maxRamp = 32 } = {}) {
122
+ checkOptions('live', arguments[1], ['worker', 'onsolution', 'maxRamp']);
121
123
  const engine = worker ? workerEngine(def, worker, maxRamp) : localEngine(def, maxRamp);
122
124
  let pending = [], busy = false;
123
125
  const api = {
package/src/particles.js CHANGED
@@ -23,6 +23,7 @@
23
23
  // and its minority carriers as dots, each one injected, drifting, diffusing and recombining.
24
24
 
25
25
  import { GAS_CONSTANT } from './constants.js';
26
+ import { checkOptions } from './errors.js';
26
27
 
27
28
  const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
28
29
 
@@ -53,6 +54,7 @@ const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea
53
54
  * @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
54
55
  */
55
56
  export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
57
+ checkOptions('particles', arguments[1], ['species', 'dots', 'weight', 'cap', 'cells', 'random']);
56
58
  const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
57
59
  for (const nm of names) if (!sol.c[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in the solution`);
58
60
  if (!(dots > 0 && Number.isFinite(dots))) throw new RangeError(`particles: dots must be a positive number, got ${dots}`);
package/src/plot.js CHANGED
@@ -14,6 +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
18
 
18
19
  // Colours by role, light and dark (validated together for colour-vision deficiency; line weight,
19
20
  // dash and the legend carry identity too). A role with more members than colours cycles.
@@ -31,6 +32,7 @@ const ROLES = ['electron', 'cation', 'anion', 'redox'];
31
32
  * @param {{ dark?: boolean }} [opts]
32
33
  */
33
34
  export function themeColor(role, slot, { dark = false } = {}) {
35
+ checkOptions('themeColor', arguments[2], ['dark']);
34
36
  const list = THEME[dark ? 'dark' : 'light'][role];
35
37
  return list[slot % list.length];
36
38
  }
@@ -140,6 +142,7 @@ const instance = Math.random().toString(36).slice(2, 8);
140
142
  * @returns {string}
141
143
  */
142
144
  export function levelChart(tr, { width = 640, height = 360, ylabel = 'voltage (V)', range, xlim, title, ytick = fmt } = {}) {
145
+ checkOptions('levelChart', arguments[1], ['width', 'height', 'ylabel', 'range', 'xlim', 'title', 'ytick']);
143
146
  const id = `driftlet-plot-${instance}-${++counter}`;
144
147
  const { x, series, regions } = tr;
145
148
  const xs = xlim ? xlim[0] : x[0], xe = xlim ? xlim[1] : x[x.length - 1];
@@ -264,6 +267,7 @@ export function levelChart(tr, { width = 640, height = 360, ylabel = 'voltage (V
264
267
  * @returns {string}
265
268
  */
266
269
  export function bandDiagram(sol, opts = {}) {
270
+ checkOptions('bandDiagram', arguments[1], ['width', 'height', 'ylabel', 'range', 'xlim', 'title', 'ytick', 'species', 'standard', 'phi', 'energy', 'levels', 'labels', 'shifts']);
267
271
  const { width, height, ylabel = opts.energy ? 'electron energy (eV)' : undefined, range, xlim, title, ytick, ...pick } = opts;
268
272
  return levelChart(traces(sol, pick), { width, height, ylabel, range, xlim, title, ytick });
269
273
  }
@@ -2,7 +2,7 @@
2
2
  // beside the electrode held as it is and the metal's level moved, which is what an Evans diagram
3
3
  // plots. Where the net current crosses zero is the spot's open-circuit (mixed) potential.
4
4
 
5
- import { DeviceError } from './errors.js';
5
+ import { DeviceError, checkOptions } from './errors.js';
6
6
  import { FARADAY } from './constants.js';
7
7
  import { powi, powr } from './pow.js';
8
8
 
@@ -30,6 +30,7 @@ const fail = (message) => {
30
30
  * positive: into the solution); and their sum
31
31
  */
32
32
  export function polarization(device, sol, where, V, opts = {}) {
33
+ checkOptions('polarization', arguments[4], ['surface']);
33
34
  const model = device.model, { species, grid, RT } = model, solver = device.solver;
34
35
  const Vs = Float64Array.from(typeof V === 'number' ? [V] : V);
35
36
  let reactions, nodeOf, carrier, names, surface = null; // (an electrode port's coverages at the spot)
@@ -114,10 +115,10 @@ export function polarization(device, sol, where, V, opts = {}) {
114
115
  const out = parts.map(({ rx, k, base, s, q, pref, surf }) => {
115
116
  const rate = Float64Array.from(Vs, (v, j) => {
116
117
  const { lnA, bare } = at[j];
117
- let a = base + s * v, p = pref * (rx.bare ? bare : 1);
118
+ let a = base + s * v, p = pref * (rx.sites ? powr(bare, rx.sites) : 1);
118
119
  for (const u of surf) {
119
120
  a -= u.nu * lnA[u.s];
120
- p *= Math.exp(u.e * lnA[u.s]);
121
+ p *= Math.exp(u.e * lnA[u.s]) * powr(bare, u.e); // its coverage θ = θ₀ a
121
122
  }
122
123
  return rx.vmax ? p * -Math.expm1(-a) : p * (Math.exp(rx.alpha * a) - Math.exp(-(1 - rx.alpha) * a));
123
124
  });
package/src/recorder.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // each animation frame), with the trace joined up across calls and whole solutions kept as
3
3
  // frames, for scrubbing back through a run or plotting snapshots side by side.
4
4
 
5
- import { DeviceError } from './errors.js';
5
+ import { DeviceError, checkOptions } from './errors.js';
6
6
 
7
7
  const fail = (message) => {
8
8
  throw new DeviceError(message);
@@ -18,6 +18,7 @@ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date
18
18
  * @param {{ every?: number, times?: number[], probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number } | { interface: number, species?: string, gate?: string })[] }} [opts]
19
19
  */
20
20
  export function recorder(device, opts = {}) {
21
+ checkOptions('recorder', arguments[1], ['every', 'times', 'probes']);
21
22
  const { every, times, probes } = opts;
22
23
  if (every !== undefined && times !== undefined) fail('recorder: give every or times, not both');
23
24
  if (every !== undefined && !(every > 0 && Number.isFinite(every))) fail('recorder: every must be a time > 0 (s)');
package/src/solution.js CHANGED
@@ -87,7 +87,7 @@ export function makeSolution(solver, result = {}) {
87
87
  }
88
88
  solver._assembleBookkeeping(solver.lastDt);
89
89
  sol.contacts = {};
90
- sol.gates = {};
90
+ sol.plates = {};
91
91
  for (const side of ['left', 'right']) {
92
92
  const ct = contacts[side];
93
93
  const flux = {};
@@ -107,7 +107,7 @@ export function makeSolution(solver, result = {}) {
107
107
  if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
108
108
  // Charge on the gate (or metal) plate, per area if the device is planar: +D at the left, −D
109
109
  // at the right.
110
- sol.gates[side] = { V, D, charge: side === 'left' ? D : -D };
110
+ sol.plates[side] = { V, D, charge: side === 'left' ? D : -D };
111
111
  }
112
112
  }
113
113
  sol.current = sol.contacts.right.current;
@@ -313,11 +313,24 @@ export function makeSolution(solver, result = {}) {
313
313
  // refined. Flag it where it lowers that capacitance by over 2%. (Where the box is the smaller
314
314
  // part, as an ε = 0 metal's against a semiconductor's depletion, it's a fine stand-in for the
315
315
  // metal's own screening.)
316
- const boxed = (g, h, phi, other, where) => {
316
+ // And a reaction at the face sees that box's composition, which holding the charge σ shifts
317
+ // by about z·σ/(Cbox·V_T) for an ion of charge z: a Frumkin effect set by the mesh, which grows
318
+ // without limit as the box shrinks. Flag it past 1%.
319
+ const boxed = (g, h, phi, other, where, sigma = 0, reacting = []) => {
317
320
  const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
318
321
  if (mat.conductor || mat.epsr !== 0 || solver.phiUndefined?.[g]) return;
319
322
  const s2 = solver.screening(g), Cbox = (F * F * s2 * h) / (2 * RT);
320
323
  if (!(s2 > 0)) return;
324
+ const ion = reacting.filter((i) => species[i].z !== 0).reduce((a, i) => (a < 0 || Math.abs(species[i].z) > Math.abs(species[a].z) ? i : a), -1);
325
+ const shift = ion < 0 ? 0 : (Math.abs(species[ion].z * sigma) / Cbox) * (F / RT);
326
+ if (shift > 0.01) {
327
+ sol.warnings.push(
328
+ `${where}: the reaction here sees the strictly neutral cell beside the face (${h.toExponential(2)} m), which holds the face's charge as a diffuse layer that wide: ` +
329
+ `${species[ion].name} there is shifted by about ${(100 * shift).toPrecision(2)}%, a Frumkin effect set by the mesh that grows as the cell shrinks. ` +
330
+ `Set the face's zeroCharge near its rest potential (so it holds little charge), coarsen that cell to ${((200 * Math.abs(species[ion].z * sigma)) / (F * s2)).toExponential(1)} m or more, ` +
331
+ 'or resolve the double layer (epsr > 0, hmin below the Debye length).',
332
+ );
333
+ }
321
334
  let C = phi.C;
322
335
  if (phi.type === 'pinned') {
323
336
  const om = other < 0 ? null : model.materials[model.regions[grid.nodeRegion[other]].material];
@@ -338,8 +351,10 @@ export function makeSolution(solver, result = {}) {
338
351
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
339
352
  check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
340
353
  check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
341
- boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`);
342
- boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`);
354
+ const reacting = (side) => itf.reactions.flatMap((rx) => rx.part.filter((p) => p.side === side).map((p) => p.i));
355
+ const sigma = sol.interfaces[f].D;
356
+ boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`, sigma, reacting(0));
357
+ boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`, sigma, reacting(1));
343
358
  });
344
359
  const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
345
360
  if (resolves(contacts.left)) {