driftlet 0.16.2 → 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.
Files changed (51) hide show
  1. package/README.md +14 -3
  2. package/docs/device.md +29 -11
  3. package/docs/kit.md +10 -1
  4. package/docs/numerics.md +68 -12
  5. package/docs/statistics.md +2 -1
  6. package/docs/templates.md +260 -0
  7. package/llms.txt +107 -299
  8. package/package.json +15 -3
  9. package/src/blockTridiagonal.js +73 -1
  10. package/src/channels.js +6 -4
  11. package/src/checks.js +78 -3
  12. package/src/data.js +5 -1
  13. package/src/device.js +1 -1
  14. package/src/devices.js +2 -0
  15. package/src/equation.js +2 -1
  16. package/src/errors.js +33 -0
  17. package/src/grid.js +12 -0
  18. package/src/index.js +21 -10
  19. package/src/junction.js +3 -1
  20. package/src/kit.js +1 -1
  21. package/src/live.js +2 -0
  22. package/src/particles.js +2 -0
  23. package/src/plot.js +4 -0
  24. package/src/polarization.js +2 -1
  25. package/src/recorder.js +2 -1
  26. package/src/solution.js +39 -5
  27. package/src/solver.js +248 -30
  28. package/src/sources.js +8 -1
  29. package/src/stack.js +14 -1
  30. package/src/statistics.js +1 -1
  31. package/src/traces.js +3 -0
  32. package/src/types.js +3 -1
  33. package/types/blockTridiagonal.d.ts +1 -0
  34. package/types/channels.d.ts +3 -3
  35. package/types/checks.d.ts +9 -3
  36. package/types/data.d.ts +3 -3
  37. package/types/devices.d.ts +1 -1
  38. package/types/equation.d.ts +1 -1
  39. package/types/errors.d.ts +8 -0
  40. package/types/index.d.ts +4 -7
  41. package/types/junction.d.ts +2 -2
  42. package/types/kit.d.ts +1 -1
  43. package/types/live.d.ts +1 -1
  44. package/types/particles.d.ts +1 -1
  45. package/types/plot.d.ts +3 -3
  46. package/types/polarization.d.ts +1 -1
  47. package/types/recorder.d.ts +1 -1
  48. package/types/solver.d.ts +8 -1
  49. package/types/sources.d.ts +7 -7
  50. package/types/traces.d.ts +1 -1
  51. package/types/types.d.ts +9 -1
@@ -124,7 +124,15 @@ export class BlockTridiagonal {
124
124
  }
125
125
  }
126
126
  }
127
- const ratio = luFactor(lu, o, piv, offX[i], m, this);
127
+ let ratio = luFactor(lu, o, piv, offX[i], m, this);
128
+ // A pivot far below the entries it's formed from may be round-off alone (the level of a
129
+ // region that only the blocks after it hold): its value, 7e-32 or 3e-16 or 0 as the
130
+ // unknowns happen to be listed, then scales all that follows (by 1e31, and the next block
131
+ // lost its own pivot outright). Redone with each entry's magnitudes carried (see
132
+ // cancellation), a pivot 1e-18 of them, all its digits lost, is perturbed like one that
133
+ // cancelled to exactly nothing. (At 1e-15 it can still hold a digit Newton needs: a port's
134
+ // steady solve wandered instead of converging.)
135
+ if (this.staticPivots && ratio < 1e-12) ratio = this._noisyPivots(i);
128
136
  if (!(ratio > 0)) {
129
137
  this.factored = false;
130
138
  this.singularBlock = i;
@@ -146,6 +154,70 @@ export class BlockTridiagonal {
146
154
  this.factored = true;
147
155
  }
148
156
 
157
+ // Block i again, its W and LU with the magnitudes each entry is formed from (as cancellation()
158
+ // does), perturbing pivots 1e-18 of theirs. Returns the block's pivot ratio.
159
+ _noisyPivots(i) {
160
+ const { A, B, sizes, offA, offB, offC, offX } = this, cp = this._cp, lu = this._lu, piv = this._piv;
161
+ const m = sizes[i], mp = i > 0 ? sizes[i - 1] : 0, oa = offA[i], o = offB[i], oc = i > 0 ? offC[i - 1] : 0;
162
+ const S = (this._mag ??= new Float64Array(this._tmp.length * this._tmp.length));
163
+ for (let r = 0; r < m; r++) {
164
+ for (let c = 0; c < m; c++) {
165
+ let v = B[o + r * m + c], sum = Math.abs(v);
166
+ for (let k = 0; k < mp; k++) {
167
+ const a = A[oa + r * mp + k];
168
+ if (a === 0) continue;
169
+ const t = a * cp[oc + k * m + c];
170
+ v -= t;
171
+ sum += Math.abs(t);
172
+ }
173
+ lu[o + r * m + c] = v;
174
+ S[r * m + c] = sum;
175
+ }
176
+ }
177
+ let maxEntry = 0;
178
+ for (let k = 0; k < m * m; k++) maxEntry = Math.max(maxEntry, Math.abs(lu[o + k]));
179
+ if (maxEntry === 0) return 0;
180
+ let minPivot = Infinity;
181
+ for (let k = 0; k < m; k++) {
182
+ let best = k, bestVal = Math.abs(lu[o + k * m + k]);
183
+ for (let r = k + 1; r < m; r++) {
184
+ const v = Math.abs(lu[o + r * m + k]);
185
+ if (v > bestVal) { best = r; bestVal = v; }
186
+ }
187
+ piv[offX[i] + k] = best;
188
+ if (best !== k) {
189
+ for (let c = 0; c < m; c++) {
190
+ let t = lu[o + k * m + c];
191
+ lu[o + k * m + c] = lu[o + best * m + c];
192
+ lu[o + best * m + c] = t;
193
+ t = S[k * m + c];
194
+ S[k * m + c] = S[best * m + c];
195
+ S[best * m + c] = t;
196
+ }
197
+ }
198
+ const sum = S[k * m + k];
199
+ if (bestVal <= 1e-18 * sum || bestVal === 0) {
200
+ const size = sum > 0 ? 1e-15 * sum : 1e-15 * maxEntry;
201
+ bestVal = size;
202
+ lu[o + k * m + k] = lu[o + k * m + k] < 0 ? -size : size;
203
+ this.perturbedPivots++;
204
+ }
205
+ if (bestVal < minPivot) minPivot = bestVal;
206
+ const inv = 1 / lu[o + k * m + k];
207
+ for (let r = k + 1; r < m; r++) {
208
+ const f = (lu[o + r * m + k] *= inv);
209
+ if (f !== 0) {
210
+ const af = Math.abs(f);
211
+ for (let c = k + 1; c < m; c++) {
212
+ lu[o + r * m + c] -= f * lu[o + k * m + c];
213
+ S[r * m + c] += af * S[k * m + c];
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return minPivot / maxEntry;
219
+ }
220
+
149
221
  /**
150
222
  * Solve with the current factorisation. `rhs` and `out` are Float64Arrays of length `size`
151
223
  * (node-major); they may be the same array.
package/src/channels.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // driftlet/kit: voltage-gated channels as plain definition data. Hodgkin and Huxley's squid axon:
2
2
  // its gates, and permeabilities for its Na⁺, K⁺ and leak channels, for a membrane face.
3
3
 
4
- import { DeviceError } from './errors.js';
4
+ import { DeviceError, checkOptions } from './errors.js';
5
5
  import { FARADAY, GAS_CONSTANT } from './constants.js';
6
6
 
7
7
  const fail = (message) => {
@@ -49,13 +49,15 @@ export function ghkCurrent(z, inside, outside, V, T) {
49
49
  * port along a region: links that are their linear conductances, G = g × area per volume, gated the
50
50
  * same, each ion's reversal potential following from the port's bath and the concentrations inside
51
51
  * (so `inside`, `outside` and `at` aren't needed).
52
- * @param {{ inside: object, outside: object, T?: number, g?: { Na?: number, K?: number, leak?: number },
52
+ * @param {{ inside: object, outside: object, T: number, g?: { Na?: number, K?: number, leak?: number },
53
53
  * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string }, linear?: boolean, area?: number }} opts concentrations in
54
54
  * mol/m³ by species name, K, S/m², V
55
55
  * @returns {{ gates: object, species: object }}
56
56
  */
57
- export function hodgkinHuxley({ inside, outside, T = HH_T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {}, linear = false, area } = {}) {
58
- need(num(T) && T > 0, `hodgkinHuxley: T must be a temperature in K, got ${T}`);
57
+ export function hodgkinHuxley({ inside, outside, T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {}, linear = false, area } = {}) {
58
+ checkOptions('hodgkinHuxley', arguments[0], ['inside', 'outside', 'T', 'g', 'leak', 'leakZ', 'at', 'names', 'linear', 'area']);
59
+ // T sets the gates' rates, so it must be the device's: never defaulted (their 6.3 °C, under a body-temperature device, doubled a spike's peak, silently).
60
+ need(num(T) && T > 0, `hodgkinHuxley: T must be the device's temperature in K (their own experiments were at 279.45 K, 6.3 °C), got ${T}`);
59
61
  need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
60
62
  const G = { Na: 1200, K: 360, leak: 3, ...g };
61
63
  const Na = names.Na ?? 'Na+', K = names.K ?? 'K+';
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
 
@@ -29,6 +30,11 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
29
30
  * zero; under light it's J = F(G − R), and it says where every carrier went.
30
31
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
31
32
  * what came in.
33
+ * - **order** (a steady state, with `refine` and `order`, the defaults): the device solved from
34
+ * its definition twice, its species listed as given and in reverse, which must give the same
35
+ * steady state (each terminal's voltage and current, each species' amount). A solve that
36
+ * depends on the listing has converged somewhere that isn't steady without noticing (round 11's
37
+ * fuzzer found it in ~1% of random reacting devices, by exactly this test).
32
38
  * - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
33
39
  * answer depends on the whole run): in a steady state, the device solved again on a grid
34
40
  * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
@@ -37,12 +43,13 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
37
43
  * (about 3/4 of it).
38
44
  * @param {import('./index.js').Device} device
39
45
  * @param {object} [sol] the solution to check (default: the device's present state)
40
- * @param {{ refine?: boolean, tol?: number }} [opts]
46
+ * @param {{ refine?: boolean, tol?: number, order?: boolean }} [opts]
41
47
  * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
42
48
  * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
43
49
  * @returns {{ ok: boolean, items: CheckItem[], text: string }}
44
50
  */
45
- export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
51
+ export function check(device, sol, { refine = true, tol = 1e-2, order = true } = {}) {
52
+ checkOptions('check', arguments[2], ['refine', 'tol', 'order']);
46
53
  sol ??= device.solution();
47
54
  const { model, solver, def } = device;
48
55
  const steady = sol.steady ?? solver.atSteady;
@@ -66,8 +73,17 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
66
73
  // The rest checks a solution, which an unconverged state isn't.
67
74
  if (sol.converged) {
68
75
  const ledger = steady ? balance(device, sol) : null;
76
+ // A reaction the steady state needs at equilibrium, left far from it (too slow to see: the
77
+ // solution warns of it), isn't steady, however quietly its ledgers sum: "equilibrium" was
78
+ // printed beside that warning.
79
+ const stuck = steady ? solver.stuckReactions() : [];
80
+ if (ledger && stuck.length > 0) {
81
+ ledger.ok = false;
82
+ ledger.summary = `not a steady state: ${stuck.map((s) => `${s.where} is ${sig(s.affinity * solver.VT * 1e3)} mV of affinity from equilibrium`).join(', ')}, too slow for the solve to see (see the warnings); its ledgers say only that nothing moves fast enough to count`;
83
+ }
69
84
  items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
70
85
  if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
86
+ if (steady && refine && order && model.species.length > 1) items.push(orderCheck(device, sol, ledger.details.gross));
71
87
  // A transient's answer depends on its whole history (and on drives set along the way), so
72
88
  // only a run of its own on a finer grid can check it.
73
89
  else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
@@ -156,7 +172,10 @@ function balance(device, sol) {
156
172
  const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
157
173
  const natural = naturalScales(device, sol), inSolution = naturalScales(device, sol, false); // (partners' scale: not a metal's conductance)
158
174
  const scale = (l) => Math.max(natural[l.i], ...l.terms.flatMap((t) => (partners.get(t.what) ?? []).map((j) => inSolution[j])));
159
- 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)));
160
179
  let worst = 0;
161
180
  const lines = [];
162
181
  for (const l of moving) {
@@ -314,6 +333,62 @@ function gridCheck(device, sol, tol, gross) {
314
333
  };
315
334
  }
316
335
 
336
+ // The device solved from its definition with its species listed as given and in reverse: the
337
+ // same steady state either way, or one of them isn't steady. (Both from the definition, not this
338
+ // state, whose closed amounts a transient or set() may have moved from c0.)
339
+ function orderCheck(device, sol, gross) {
340
+ const { def, model } = device;
341
+ const solveAt = (d) => {
342
+ const dev = new Device(d);
343
+ if (sol.time > 0) dev.solver.time = sol.time; // waveforms at the same time
344
+ return dev.solve();
345
+ };
346
+ let a, b;
347
+ try {
348
+ a = solveAt(def);
349
+ b = solveAt({ ...def, species: [...def.species].reverse() });
350
+ } catch (e) {
351
+ return { name: 'order', ok: null, summary: `could not solve the device with its species reversed (${e.message})` };
352
+ }
353
+ if (!a.converged || !b.converged) {
354
+ const which = a.converged ? 'listed in reverse' : b.converged ? 'listed as given' : 'listed either way';
355
+ return { name: 'order', ok: null, summary: `the device solved from its definition did not converge with its species ${which}: compare with a long advance() by hand` };
356
+ }
357
+ const VT = model.RT / FARADAY, diffs = [];
358
+ // (Currents only where the ledgers see something move: in equilibrium they're round-off of
359
+ // whatever exchanges fastest, an electrode's couple, and a wrong state shows in the voltages
360
+ // and amounts instead.)
361
+ // (and above 1e-16 of what the species could carry, as the grid check's floor: a drift at
362
+ // 1e-16 A/m², still moving at 1e12 s, isn't worth reporting)
363
+ const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
364
+ for (const [name, t] of Object.entries(a.terminals)) {
365
+ const u = b.terminals[name];
366
+ diffs.push({ what: `${name} voltage`, a: t.V, b: u.V, rel: Math.abs(u.V - t.V) / (1e-6 * Math.max(VT, Math.abs(t.V))), unit: 'V' });
367
+ if (gross > 0 && Math.max(Math.abs(t.current), Math.abs(u.current), gross) > floorI) diffs.push({ what: `${name} current`, a: t.current, b: u.current, rel: Math.abs(u.current - t.current) / (1e-3 * Math.max(Math.abs(t.current), Math.abs(u.current), gross)), unit: unitsOf(model).current });
368
+ }
369
+ const vol = model.grid.vol, amount = (s, name) => s.x.reduce((m, _, g) => m + (Number.isFinite(s.c[name][g]) ? vol[g] * s.c[name][g] : 0), 0);
370
+ const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
371
+ const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
372
+ for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: 'mol/m²' });
373
+ // Agreeing to near round-off (a µV, a millionth of an amount): ok. Within a thousand times that
374
+ // (a mV, a thousandth), enough for any plot, but a weakly held part of one solve not quite
375
+ // steady: look. Beyond: one of them isn't a steady state.
376
+ const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
377
+ const ok = !(worst.rel > 1) ? true : worst.rel > 1e3 ? false : null;
378
+ const what = `${worst.what} ${sig(worst.a, 6)} → ${sig(worst.b, 6)} ${worst.unit}`;
379
+ return {
380
+ name: 'order',
381
+ ok,
382
+ summary:
383
+ ok === true
384
+ ? 'the same steady state with the species listed in reverse'
385
+ : ok === null
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`
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.`,
388
+ details: { diffs },
389
+ };
390
+ }
391
+
317
392
  // Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
318
393
  function rho(s, g, model, r) {
319
394
  let q = model.regions[r].fixedCharge ?? 0;
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
 
@@ -63,8 +67,8 @@ export class Device {
63
67
  }
64
68
 
65
69
  /**
66
- * Change part of the definition (deep-merged). A contact given V or I drops the other, so
67
- * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
70
+ * Change part of the definition (deep-merged). A contact given V or I drops the other (and
71
+ * given I, any series R), so `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
68
72
  * `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
69
73
  * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
70
74
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
@@ -73,7 +77,8 @@ export class Device {
73
77
  */
74
78
  set(patch) {
75
79
  // A terminal given a new kind of drive drops the old one: { I: 0 } replaces a held V.
76
- const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
80
+ // (A current drives a terminal alone: given I, a contact drops V and any series R too.)
81
+ const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined, ...('R' in c ? {} : { R: undefined }) } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
77
82
  for (const side of ['left', 'right']) {
78
83
  const c = patch?.contacts?.[side];
79
84
  if (c && typeof c === 'object') patch = { ...patch, contacts: { ...patch.contacts, [side]: redrive(c) } };
@@ -178,6 +183,7 @@ export class Device {
178
183
  * @returns {Solution}
179
184
  */
180
185
  solve(opts) {
186
+ options(opts, 'solve', ['maxSteps', 'tol', 'continuation']);
181
187
  // A solve that fails reports the state it failed in, and leaves the device as it was: a
182
188
  // device with no steady state (a host filling at a current) can still advance() from there.
183
189
  const snap = this.solver._snapshot();
@@ -195,6 +201,7 @@ export class Device {
195
201
  * @returns {Solution}
196
202
  */
197
203
  step(dt, opts) {
204
+ options(opts, 'step', ['method']);
198
205
  return makeSolution(this.solver, this.solver.advance(dt, opts));
199
206
  }
200
207
 
@@ -210,22 +217,26 @@ export class Device {
210
217
  * @returns {Solution}
211
218
  */
212
219
  advance(tEnd, opts) {
220
+ options(opts, 'advance', ['tol', 'dt0', 'dtMax', 'budgetMs', 'maxSteps', 'method', 'probes']);
213
221
  return makeSolution(this.solver, this.solver.integrate(tEnd, opts));
214
222
  }
215
223
 
216
224
  /**
217
225
  * Small-signal impedance Z(f) about the steady state (solved first), at one terminal: its
218
226
  * held voltage is perturbed (or its driven current), the others keep their drives.
219
- * 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`.
220
228
  * @param {ArrayLike<number>} frequencies Hz
221
229
  * @param {{ terminal?: string, profiles?: boolean }} [opts] the terminal (default 'right'), and
222
230
  * whether to return complex profiles per frequency
223
231
  * @returns {import('./types.js').ImpedanceResult}
224
232
  */
225
233
  impedance(frequencies, opts) {
234
+ options(opts, 'impedance', ['terminal', 'profiles']);
226
235
  const steady = this.solver.solveSteady();
227
236
  if (!steady.converged) throw new SolverError('impedance: the steady state did not converge');
228
- 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 };
229
240
  }
230
241
 
231
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)');