driftlet 1.1.0 → 1.2.1

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