driftlet 0.16.4 → 1.0.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/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
@@ -1316,7 +1316,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1316
1316
  fixed !== 0
1317
1317
  ? `${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
1318
  `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)`,
1319
+ : `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge): add the counter-ions' share`,
1320
1320
  );
1321
1321
  // The reference species pins the bath's φ; every other species follows from composition.
1322
1322
  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)
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)) {
package/src/solver.js CHANGED
@@ -1905,6 +1905,22 @@ export class Solver {
1905
1905
  });
1906
1906
  if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1907
1907
  }
1908
+ // A floating port linked to its carrier (no reactions, no double layer: a wire into an
1909
+ // insertion host) starts at that carrier's level across its window, not at 0 V.
1910
+ for (const k of this.floating) {
1911
+ const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1912
+ const i = port?.terminal, link = i === null || i === undefined ? null : port.species[i];
1913
+ if (!port || port.reactions.length > 0 || port.capacitance || !link || link.type === 'blocked' || z[i] === 0) continue;
1914
+ let w = 0, sum = 0;
1915
+ port.nodes.forEach((g, j) => {
1916
+ const a = this.model.grid.vol[g] * (this.portArea[t.index]?.[j] ?? 1); // (a window with no area: by volume)
1917
+ w += a;
1918
+ sum += (a * this.VT * u[this.blockOfNode[g] * M + 1 + i]) / z[i];
1919
+ });
1920
+ // (Levels at the start mean something only against a held terminal's, as for a contact.)
1921
+ const held = [0, 1].find((h) => !this.floating.includes(h) && Number.isFinite(level(this.terms[h].side)));
1922
+ if (w > 0 && held !== undefined) this.termV[k] = this.termV[held] + sum / w - (link.offset ?? 0) - level(this.terms[held].side);
1923
+ }
1908
1924
  // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
1909
1925
  for (const gt of this.gateList) {
1910
1926
  const { gate, o } = gt, V = this._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
@@ -3967,8 +3983,12 @@ export class Solver {
3967
3983
  const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
3968
3984
  let steps = 0, rejected = 0, iterations = 0, failed = false;
3969
3985
  // Already there (an animation frame with no time to add): nothing to do, and the step size
3970
- // carried to the next call stays as it was.
3971
- if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
3986
+ // carried to the next call stays as it was. A time already passed, or reached again outside
3987
+ // an animation, is most likely advance(dt) read as a duration: say so.
3988
+ if (!(tEnd > this.time)) {
3989
+ const relative = tEnd < this.time || (this.time > 0 && opts.budgetMs === undefined);
3990
+ return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace, ...(relative ? { stopped: `advance(${tEnd}) did nothing: ${absolute}. To run for a further dt, advance(sol.time + dt)` } : {}) };
3991
+ }
3972
3992
  const t0 = this.time;
3973
3993
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
3974
3994
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
package/src/sources.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // driftlet/kit: sources as plain definition data. Waveforms for a terminal's V or I, an injector
2
2
  // port, and band-to-band recombination from a lifetime. Each returns an ordinary spec value.
3
3
 
4
- import { DeviceError } from './errors.js';
4
+ import { DeviceError, checkOptions } from './errors.js';
5
5
 
6
6
  const fail = (message) => {
7
7
  throw new DeviceError(message);
@@ -19,6 +19,7 @@ const num = (v) => typeof v === 'number' && Number.isFinite(v);
19
19
  * @returns {{ t: number[], values: number[] }}
20
20
  */
21
21
  export function pulse({ start = 0, width, amplitude, rise = 0, base = 0 } = {}) {
22
+ checkOptions('pulse', arguments[0], ['start', 'width', 'amplitude', 'rise', 'base']);
22
23
  need(num(width) && width > 0, `pulse: width must be > 0 (s), got ${width}`);
23
24
  need(num(amplitude), `pulse: amplitude must be a finite number (V or A/m²), got ${amplitude}`);
24
25
  need(num(start) && start >= 0, `pulse: start must be ≥ 0 (s), got ${start}`);
@@ -35,6 +36,7 @@ export function pulse({ start = 0, width, amplitude, rise = 0, base = 0 } = {})
35
36
  * @returns {{ t: number[], values: number[], repeat: true }}
36
37
  */
37
38
  export function square({ high, low, period, duty = 0.5 } = {}) {
39
+ checkOptions('square', arguments[0], ['high', 'low', 'period', 'duty']);
38
40
  need(num(high) && num(low), `square: high and low must be finite numbers, got ${high} and ${low}`);
39
41
  need(num(period) && period > 0, `square: period must be > 0 (s), got ${period}`);
40
42
  need(num(duty) && duty > 0 && duty < 1, `square: duty must be between 0 and 1, got ${duty}`);
@@ -49,6 +51,7 @@ export function square({ high, low, period, duty = 0.5 } = {}) {
49
51
  * @returns {{ t: number[], values: number[], repeat: true }}
50
52
  */
51
53
  export function triangle({ from, to, period } = {}) {
54
+ checkOptions('triangle', arguments[0], ['from', 'to', 'period']);
52
55
  need(num(from) && num(to), `triangle: from and to must be finite numbers, got ${from} and ${to}`);
53
56
  need(num(period) && period > 0, `triangle: period must be > 0 (s), got ${period}`);
54
57
  return { t: [0, period / 2, period], values: [from, to, from], repeat: true };
@@ -60,6 +63,7 @@ export function triangle({ from, to, period } = {}) {
60
63
  * @returns {{ t: number[], values: number[] }}
61
64
  */
62
65
  export function ramp({ from, to, duration, start = 0 } = {}) {
66
+ checkOptions('ramp', arguments[0], ['from', 'to', 'duration', 'start']);
63
67
  need(num(from) && num(to), `ramp: from and to must be finite numbers, got ${from} and ${to}`);
64
68
  need(num(duration) && duration > 0, `ramp: duration must be > 0 (s), got ${duration}`);
65
69
  need(num(start) && start >= 0, `ramp: start must be ≥ 0 (s), got ${start}`);
@@ -78,6 +82,7 @@ export function ramp({ from, to, duration, start = 0 } = {}) {
78
82
  * @returns {object} a port, for the definition's `ports`
79
83
  */
80
84
  export function injector({ name = 'injector', region, from, to, species, I, headroom = 2.5 } = {}) {
85
+ checkOptions('injector', arguments[0], ['name', 'region', 'from', 'to', 'species', 'I', 'headroom']);
81
86
  need(region !== undefined, 'injector: give the region (its name or index)');
82
87
  need(num(from) && num(to) && to > from, `injector: from and to must bound the window, in m from the region's left end (got ${from}, ${to})`);
83
88
  need(typeof species === 'string', 'injector: give the species it injects, e.g. species: \'h+\'');
@@ -96,6 +101,7 @@ export function injector({ name = 'injector', region, from, to, species, I, head
96
101
  * @returns {{ equation: string, kf: Record<string, number> }} a bulk reaction
97
102
  */
98
103
  export function recombination({ material, tau, majority } = {}) {
104
+ checkOptions('recombination', arguments[0], ['material', 'tau', 'majority']);
99
105
  need(typeof material === 'string', 'recombination: give the material, by name');
100
106
  need(num(tau) && tau > 0, `recombination: tau must be > 0 (s), got ${tau}`);
101
107
  need(num(majority) && majority > 0, `recombination: majority must be the majority carrier density > 0 (mol/m³; units.perCm3 converts), got ${majority}`);
@@ -117,6 +123,7 @@ export function recombination({ material, tau, majority } = {}) {
117
123
  * @returns {{ equation: string, fixed: Record<string, number>, kf: Record<string, { x: number[], values: number[] }> }} a bulk reaction
118
124
  */
119
125
  export function photogeneration({ material, flux, alpha, mu, from = 0, to, makes = 'e- + h+' } = {}) {
126
+ checkOptions('photogeneration', arguments[0], ['material', 'flux', 'alpha', 'mu', 'from', 'to', 'makes']);
120
127
  need(typeof material === 'string', 'photogeneration: give the material, by name');
121
128
  need(num(flux) && flux >= 0, `photogeneration: flux must be the photon flux ≥ 0 entering the material (mol/(m²·s)), got ${flux}`);
122
129
  need(num(alpha) && alpha > 0, `photogeneration: alpha must be the absorption coefficient > 0 (1/m; 1/cm × 100), got ${alpha}`);
package/src/stack.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // that you didn't write: it only moves things to where the definition keeps them, and converts
5
5
  // the kit's doping shorthand (donors and acceptors).
6
6
 
7
- import { DeviceError } from './errors.js';
7
+ import { DeviceError, checkOptions } from './errors.js';
8
8
  import { FARADAY } from './constants.js';
9
9
 
10
10
  const fail = (message) => {
@@ -158,8 +158,21 @@ export function build(def) {
158
158
  }
159
159
  if (pendingFace) fail(`${at(pendingFace.k)}: a face after the last layer (the contact is the boundary there)`);
160
160
  if (regions.length === 0) fail('build.stack has no layers');
161
+ // A contact holding a carrier its layer lacks: most often ohmic()'s default on a metal.
162
+ for (const [k, end] of [[0, regions[0]], [stack.length - 1, regions[regions.length - 1]]]) {
163
+ const held = isObject(stack[k].species) ? Object.keys(stack[k].species).filter((s) => stack[k].species[s] !== 'blocked') : [];
164
+ const mat = materials[end.material];
165
+ if (!isObject(mat)) continue;
166
+ const has = (s) => (isObject(mat.conductor) ? mat.conductor.species === s : isObject(mat.species) && mat.species[s] !== undefined);
167
+ const lacks = held.filter((s) => !has(s));
168
+ if (lacks.length === 0) continue;
169
+ const kept = held.filter(has);
170
+ const hint = stack[k].phi === 'bulk' && kept.length > 0 ? `: write ohmic(V, [${kept.map((s) => `'${s}'`).join(', ')}]) (ohmic() holds e- and h+ unless told${isObject(mat.conductor) ? '; a metal has only its carrier' : ''})` : '';
171
+ fail(`${at(k)}: the contact holds ${lacks.map((s) => `'${s}'`).join(' and ')}, which its layer's material '${end.material}' doesn't have${hint}`);
172
+ }
161
173
 
162
174
  function region(item, path) {
175
+ checkOptions(path, item, ['name', 'material', 'length', 'fixedCharge', 'c0', 'velocity', 'mixing', 'grid', 'donors', 'acceptors']);
163
176
  const { donors, acceptors, ...r } = item;
164
177
  if (donors !== undefined || acceptors !== undefined) {
165
178
  if (r.fixedCharge !== undefined) fail(`${path}: give doping as donors and acceptors, or fixedCharge, not both`);
package/src/statistics.js CHANGED
@@ -157,7 +157,7 @@ function tableOccupancy(xs, zs, fail) {
157
157
  const x = k / 4000;
158
158
  const q = r(x);
159
159
  if (x > 0 && x < 1 && !(1 + x * (1 - x) * q.d > 1e-9)) {
160
- fail(`ocv: the interpolated curve isn't monotone near x = ${x.toFixed(3)} (E must fall steadily with x; add points or smooth the data)`);
160
+ fail(`ocv: near x = ${x.toFixed(3)} the curve is flat or rising against the ideal-solution slope, (RT/F) ln(x/(1−x)), as on a two-phase plateau (not supported): more points alone won't help; give it some of that slope, adding −a (RT/F) ln(x/(1−x)) to E (a = 0.1 passes at points 0.01 apart, less with points closer near the ends; docs/statistics.md)`);
161
161
  }
162
162
  rmin = Math.min(rmin, q.v);
163
163
  rmax = Math.max(rmax, q.v);
package/src/traces.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // range. Plain data, for any plotting toolkit (driftlet/plot draws it as SVG).
4
4
 
5
5
  import { level } from './equation.js';
6
+ import { DeviceError, checkOptions } from './errors.js';
6
7
 
7
8
  const finite = (a) => {
8
9
  for (let k = 0; k < a.length; k++) if (Number.isFinite(a[k])) return true;
@@ -78,6 +79,8 @@ export function typeset(name, z) {
78
79
  * faces: number[], range: [number, number] }}
79
80
  */
80
81
  export function traces(sol, { species, standard = true, phi = false, energy = false, levels = [], labels = {}, shifts = {} } = {}) {
82
+ checkOptions('traces', arguments[1], ['species', 'standard', 'phi', 'energy', 'levels', 'labels', 'shifts']);
83
+ if (!Array.isArray(levels)) throw new DeviceError("traces: levels must be a list, [{ half }, …], of the half-reactions whose levels to draw");
81
84
  const names = Object.keys(sol.V);
82
85
  const role = (name) => speciesRole(sol, name).role;
83
86
  const slotOf = (name) => speciesRole(sol, name).slot;