driftlet 0.14.0 → 0.16.2

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/particles.js CHANGED
@@ -47,53 +47,72 @@ const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea
47
47
  * @param {number} [opts.dots] about how many dots per species (default 200), for the default weight
48
48
  * @param {number|object} [opts.weight] amount per dot, mol/m²: one number for every species (so
49
49
  * their dots compare), or { name: weight }; default, each species' total over `dots`
50
- * @param {number} [opts.cap] the most dots a cell holds on average before it's a sea (default ∞)
50
+ * @param {number} [opts.cap] the most dots a cell of the asked-for width holds on average before
51
+ * it's a sea (a density: a wider cell holds more; default ∞)
51
52
  * @param {number} [opts.cells] about how many cells across the device (default 120)
52
53
  * @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
53
54
  */
54
55
  export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
55
56
  const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
56
- const nn = sol.x.length, x = sol.x;
57
+ for (const nm of names) if (!sol.c[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in the solution`);
58
+ if (!(dots > 0 && Number.isFinite(dots))) throw new RangeError(`particles: dots must be a positive number, got ${dots}`);
59
+ if (!(cells >= 1 && Number.isFinite(cells))) throw new RangeError(`particles: cells must be a number from 1 up, got ${cells}`);
60
+ if (!(cap >= 0)) throw new RangeError(`particles: cap must be a number from 0 up, got ${cap}`);
61
+ const MAX = 1e6; // the most dots a swarm will draw
57
62
 
58
63
  // The lattice: each region's nodes grouped into runs of node boxes about L/cells wide, so a
59
64
  // cell boundary is a segment, where the solution's flux is. (A metal's nodes, with no
60
- // concentration, are left out.)
61
- const box = Float64Array.from(x, (_, g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? x[g] - x[g - 1] : 0) + (g < nn - 1 && sol.region[g + 1] === sol.region[g] ? x[g + 1] - x[g] : 0)) / 2);
62
- const target = (x[nn - 1] - x[0]) / cells;
63
- const lattice = [];
64
- sol.regions.forEach((_, r) => {
65
- const g0 = sol.region.indexOf(r), g1 = sol.region.lastIndexOf(r);
66
- if (names.every((nm) => !(sol.c[nm][g0] >= 0))) return;
67
- const start = lattice.length;
68
- let a = g0, w = 0;
69
- for (let g = g0; g <= g1; g++) {
70
- w += box[g];
71
- if (w >= target || g === g1) {
72
- lattice.push({ a, b: g, region: r, h: w });
73
- [a, w] = [g + 1, 0];
65
+ // concentration, are left out.) Drawn again for a solution on another grid.
66
+ let x, nn, box, lattice, nc, at, target;
67
+ function draw(sol) {
68
+ x = Float64Array.from(sol.x);
69
+ nn = x.length;
70
+ box = Float64Array.from(x, (_, g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? x[g] - x[g - 1] : 0) + (g < nn - 1 && sol.region[g + 1] === sol.region[g] ? x[g + 1] - x[g] : 0)) / 2);
71
+ target = (x[nn - 1] - x[0]) / cells;
72
+ lattice = [];
73
+ sol.regions.forEach((_, r) => {
74
+ const g0 = sol.region.indexOf(r), g1 = sol.region.lastIndexOf(r);
75
+ if (names.every((nm) => !(sol.c[nm][g0] >= 0))) return;
76
+ const start = lattice.length;
77
+ let a = g0, w = 0;
78
+ for (let g = g0; g <= g1; g++) {
79
+ w += box[g];
80
+ // a run ends where one more node's box would take it farther from the target width
81
+ if (g === g1 || w + box[g + 1] - target > target - w) {
82
+ lattice.push({ a, b: g, region: r, h: w });
83
+ [a, w] = [g + 1, 0];
84
+ }
74
85
  }
86
+ const last = lattice.length - 1;
87
+ if (last > start && lattice[last].h < target / 2) {
88
+ // a thin last cell joins the one before it
89
+ lattice[last - 1].b = lattice[last].b;
90
+ lattice[last - 1].h += lattice[last].h;
91
+ lattice.pop();
92
+ }
93
+ });
94
+ for (const cl of lattice) {
95
+ cl.x0 = x[cl.a] - (cl.a > 0 && sol.region[cl.a - 1] === cl.region ? (x[cl.a] - x[cl.a - 1]) / 2 : 0);
96
+ cl.x1 = cl.x0 + cl.h;
97
+ let mid = cl.a;
98
+ for (let g = cl.a; g <= cl.b; g++) if (Math.abs(x[g] - (cl.x0 + cl.x1) / 2) < Math.abs(x[mid] - (cl.x0 + cl.x1) / 2)) mid = g;
99
+ cl.mid = mid;
75
100
  }
76
- const last = lattice.length - 1;
77
- if (last > start && lattice[last].h < target / 2) {
78
- // a thin last cell joins the one before it
79
- lattice[last - 1].b = lattice[last].b;
80
- lattice[last - 1].h += lattice[last].h;
81
- lattice.pop();
82
- }
83
- });
84
- for (const cl of lattice) {
85
- cl.x0 = x[cl.a] - (cl.a > 0 && sol.region[cl.a - 1] === cl.region ? (x[cl.a] - x[cl.a - 1]) / 2 : 0);
86
- cl.x1 = cl.x0 + cl.h;
87
- let mid = cl.a;
88
- for (let g = cl.a; g <= cl.b; g++) if (Math.abs(x[g] - (cl.x0 + cl.x1) / 2) < Math.abs(x[mid] - (cl.x0 + cl.x1) / 2)) mid = g;
89
- cl.mid = mid;
101
+ nc = lattice.length;
102
+ // where each boundary is: k between cells k − 1 and k
103
+ at = Float64Array.from({ length: nc + 1 }, (_, k) => (k < nc ? lattice[k].x0 : lattice[nc - 1].x1));
104
+ swarm.cells = lattice.map(({ x0, x1, region }) => ({ x0, x1, region }));
105
+ swarm.dots = [];
106
+ for (const nm of names) swarm.crossed[nm] = { up: new Float64Array(nc + 1), down: new Float64Array(nc + 1) };
107
+ for (const nm of names) delete S[nm];
90
108
  }
91
- const nc = lattice.length;
92
- // where each boundary is: k between cells k − 1 and k
93
- const at = Float64Array.from({ length: nc + 1 }, (_, k) => (k < nc ? lattice[k].x0 : lattice[nc - 1].x1));
109
+ // A sea where a cell would hold more than cap dots for each target width it spans: a density,
110
+ // so a wider cell among the rest doesn't become a sea alone.
111
+ const dense = (m, K) => m > (cap * lattice[K].h) / target;
112
+ const sameGrid = (s) => s.x.length === nn && s.x.every((v, g) => v === x[g]);
94
113
 
95
114
  const swarm = {
96
- cells: lattice.map(({ x0, x1, region }) => ({ x0, x1, region })),
115
+ cells: [],
97
116
  species: names,
98
117
  weight: {}, // mol/m² per dot
99
118
  sea: {}, // per species, per cell: 1 where it's a sea
@@ -123,7 +142,7 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
123
142
  const c = sol.c[nm], mu = sol.mu[nm], D = sol.D[nm], J = sol.flux[nm], RT = GAS_CONSTANT * sol.T;
124
143
  const N = amounts(sol, nm), w = swarm.weight[nm];
125
144
  const on = Uint8Array.from(lattice, (cl) => (c[cl.mid] >= 0 ? 1 : 0));
126
- const sea = Uint8Array.from(N, (n, K) => (on[K] && n / w > cap ? 1 : 0));
145
+ const sea = Uint8Array.from(N, (n, K) => (on[K] && dense(n / w, K) ? 1 : 0));
127
146
  const psi = Float64Array.from(lattice, (cl, K) => mu[cl.mid] / RT - Math.log(N[K] / cl.h));
128
147
  // Boundary k: what lies to each side, its one-way fluxes (mol/(m²·s)), and whether it's a face
129
148
  // with one-way fluxes of its own (a membrane), whose crossings between seas are events.
@@ -193,7 +212,9 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
193
212
  if (rest > 0) made[K] += rest;
194
213
  else unmade[K] -= rest;
195
214
  }
196
- return { N, on, sea, left, right, face, up, down, made, unmade, time: sol.time };
215
+ // Where in a cell a dot sits: in a node's box, chosen by its share of the cell's amount.
216
+ const share = Float64Array.from(x, (_, g) => (c[g] > 0 ? c[g] * box[g] : 0));
217
+ return { N, on, sea, left, right, face, up, down, made, unmade, share, time: sol.time };
197
218
  }
198
219
 
199
220
  /**
@@ -202,10 +223,17 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
202
223
  * steady states, which has no path in time to follow), each cell's dots are thinned or added to
203
224
  * by the change in what it's expected to hold, m → m′: each kept with probability m′/m if it
204
225
  * falls, Poisson(m′ − m) more if it rises, which takes a Poisson sample of the one into a
205
- * Poisson sample of the other, moving as few dots as can be.
226
+ * Poisson sample of the other, moving as few dots as can be. A solution on another grid (a
227
+ * resized device) redraws the lattice, and the dots are placed afresh.
206
228
  */
207
229
  function update(next, { resample = false } = {}) {
230
+ if (!next?.x || !next.c) throw new TypeError('particles: update takes a solution');
231
+ for (const nm of names) if (!next.c[nm]) throw new RangeError(`particles: the new solution has no species ${JSON.stringify(nm)}`);
208
232
  sol = next;
233
+ if (!sameGrid(sol)) draw(sol); // (a resized device: the dots placed afresh)
234
+ let total = 0;
235
+ for (const nm of names) total += amounts(sol, nm).reduce((a, n, K) => a + (dense(n / swarm.weight[nm], K) ? 0 : n), 0) / swarm.weight[nm];
236
+ if (total > MAX) throw new RangeError(`particles: this would draw ${total.toPrecision(2)} dots; give a larger weight, or a cap (dense cells as seas)`);
209
237
  for (const nm of names) {
210
238
  const was = S[nm];
211
239
  S[nm] = fields(sol, nm, was);
@@ -229,10 +257,23 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
229
257
 
230
258
  /** The dots expected in each cell for a species, at the solution's concentrations. */
231
259
  function expected(nm) {
260
+ if (!S[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in this swarm (${names.join(', ')})`);
232
261
  return Float64Array.from(S[nm].N, (n) => n / swarm.weight[nm]);
233
262
  }
234
263
 
235
- const dot = (nm, K) => ({ species: nm, cell: K, x: lattice[K].x0 + random() * lattice[K].h });
264
+ // A place in cell K for a dot of species nm: a node's box, by its share of the amount, then
265
+ // anywhere in that box. (A dot follows the profile within its cell, as at a depletion edge.)
266
+ function place(nm, K) {
267
+ const cl = lattice[K], share = S[nm]?.share;
268
+ let g = cl.a;
269
+ if (share && S[nm].N[K] > 0) {
270
+ let u = random() * S[nm].N[K];
271
+ while (g < cl.b && (u -= share[g]) > 0) g++;
272
+ } else return cl.x0 + random() * cl.h;
273
+ const lo = g > cl.a ? (x[g - 1] + x[g]) / 2 : cl.x0, hi = g < cl.b ? (x[g] + x[g + 1]) / 2 : cl.x1;
274
+ return lo + random() * (hi - lo);
275
+ }
276
+ const dot = (nm, K) => ({ species: nm, cell: K, x: place(nm, K) });
236
277
 
237
278
  // A Poisson count of mean m (a normal one for large m).
238
279
  const gauss = () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random());
@@ -246,13 +287,36 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
246
287
 
247
288
  /**
248
289
  * Move the dots on by dt (s, the device's time): each hops, and dots are made and unmade, at
249
- * the solution's rates. A dot hops at most maxHops times in a step.
290
+ * the solution's rates. A cell a dot would hop out of more than maxHops/4 times in the step is
291
+ * a sea for the step (whatever crosses its sides counted at the one-way fluxes, dots walking in
292
+ * absorbed), then drawn afresh, a new sample of what it holds: so a step much longer than the
293
+ * hop time costs little, and stays a sample of the solution. A dt that's NaN or negative (a
294
+ * first frame, a clock that stepped back) moves nothing.
250
295
  */
251
296
  function step(dt, { maxHops = 200 } = {}) {
297
+ // (A first frame or a clock stepping back, NaN or negative, moves nothing.)
252
298
  swarm.events = [];
299
+ if (dt === Infinity) throw new RangeError('particles: step(dt) takes a finite time (s), got Infinity');
300
+ if (!(dt > 0)) return swarm;
301
+ const fast = {}, saved = {};
302
+ for (const nm of names) {
303
+ const F = S[nm];
304
+ fast[nm] = Uint8Array.from(F.N, (N, K) => {
305
+ if (!F.on[K] || F.sea[K] || !(N > 0)) return 0;
306
+ const rate = ((F.right[K + 1] ? F.up[K + 1] : 0) + (F.left[K] ? F.down[K] : 0) + F.unmade[K]) / N;
307
+ return rate * dt > maxHops / 4 ? 1 : 0;
308
+ });
309
+ if (!fast[nm].some(Boolean)) continue;
310
+ // For this step, those cells are seas.
311
+ saved[nm] = [F.sea, F.left, F.right];
312
+ F.sea = Uint8Array.from(F.sea, (v, K) => v | fast[nm][K]);
313
+ F.left = Uint8Array.from(F.left, (v, k) => (v && k > 0 && fast[nm][k - 1] ? OUTSIDE : v));
314
+ F.right = Uint8Array.from(F.right, (v, k) => (v && k < nc && fast[nm][k] ? OUTSIDE : v));
315
+ }
253
316
  // Births first, each at a random moment of the step: made in a cell, or arriving from beyond
254
317
  // (a sea, a contact, a metal). Each then moves for the rest of the step. And crossings
255
- // between seas through a face.
318
+ // between seas: through a face, as events; between cells that are seas only for this step,
319
+ // counted.
256
320
  const born = [];
257
321
  for (const nm of names) {
258
322
  const F = S[nm], w = swarm.weight[nm], cr = swarm.crossed[nm];
@@ -264,36 +328,46 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
264
328
  if (!(rate > 0)) continue;
265
329
  for (let j = poisson(rate * dt); j > 0; j--) {
266
330
  const u = random() * rate, d = dot(nm, K);
331
+ // (no 'in' from a cell that's a sea only for this step)
267
332
  if (u < fromLeft) {
268
333
  cr.up[K]++;
269
- swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
334
+ if (!fast[nm][K - 1]) swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
270
335
  } else if (u < fromLeft + fromRight) {
271
336
  cr.down[K + 1]++;
272
- swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
337
+ if (!fast[nm][K + 1]) swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
273
338
  } else swarm.events.push({ species: nm, kind: 'made', x: d.x });
274
339
  born.push([d, random() * dt]);
275
340
  }
276
341
  }
277
342
  for (let k = 0; k <= nc; k++) {
278
- if (!F.face[k] || F.left[k] !== OUTSIDE || F.right[k] !== OUTSIDE) continue;
343
+ if (F.left[k] !== OUTSIDE || F.right[k] !== OUTSIDE) continue;
344
+ const events = F.face[k] && !(fast[nm][k - 1] || fast[nm][k]);
345
+ if (!events && !(fast[nm][k - 1] || fast[nm][k])) continue;
279
346
  for (const [dir, flux, count] of [[1, F.up[k], cr.up], [-1, F.down[k], cr.down]]) {
280
- for (let j = poisson((flux / w) * dt); j > 0; j--) {
281
- count[k]++;
282
- swarm.events.push({ species: nm, kind: 'cross', x: at[k], dir });
283
- }
347
+ const n = poisson((flux / w) * dt);
348
+ count[k] += n;
349
+ if (events) for (let j = 0; j < n; j++) swarm.events.push({ species: nm, kind: 'cross', x: at[k], dir });
284
350
  }
285
351
  }
286
352
  }
287
353
  const keep = [];
288
- for (const d of swarm.dots) if (walk(d, dt, maxHops)) keep.push(d);
289
- for (const [d, t] of born) if (walk(d, dt - t, maxHops)) keep.push(d);
354
+ for (const d of swarm.dots) if (!fast[d.species][d.cell] && walk(d, dt, maxHops, fast[d.species])) keep.push(d);
355
+ for (const [d, t] of born) if (walk(d, dt - t, maxHops, fast[d.species])) keep.push(d);
290
356
  swarm.dots = keep;
357
+ // The step's seas drawn afresh, and back to cells of dots.
358
+ for (const nm of Object.keys(saved)) {
359
+ const F = S[nm], w = swarm.weight[nm];
360
+ [F.sea, F.left, F.right] = saved[nm];
361
+ fast[nm].forEach((f, K) => {
362
+ if (f) for (let j = poisson(F.N[K] / w); j > 0; j--) swarm.dots.push(dot(nm, K));
363
+ });
364
+ }
291
365
  swarm.time += dt;
292
366
  return swarm;
293
367
  }
294
368
 
295
369
  // A dot's hops for a time dt (Gillespie's: exact for rates held fixed): whether it's still in.
296
- function walk(d, dt, maxHops) {
370
+ function walk(d, dt, maxHops, fast) {
297
371
  const F = S[d.species], cr = swarm.crossed[d.species];
298
372
  let t = 0;
299
373
  for (let hop = 0; hop < maxHops; hop++) {
@@ -312,20 +386,22 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
312
386
  const k = u < r ? K + 1 : K, dir = u < r ? 1 : -1;
313
387
  (dir > 0 ? cr.up : cr.down)[k]++;
314
388
  if ((dir > 0 ? F.right[k] : F.left[k]) === OUTSIDE) {
315
- swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
389
+ if (!fast?.[K + dir]) swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
316
390
  return false;
317
391
  }
318
392
  d.cell = K + dir;
319
- d.x = lattice[d.cell].x0 + random() * lattice[d.cell].h;
393
+ d.x = place(d.species, d.cell);
320
394
  }
321
395
  return true;
322
396
  }
323
397
 
324
- // Weights, then the first dots, placed by the concentration.
398
+ // The lattice and weights, then the first dots, placed by the concentration.
399
+ draw(sol);
325
400
  for (const nm of names) {
326
401
  const total = amounts(sol, nm).reduce((a, b) => a + b, 0);
327
- swarm.weight[nm] = typeof weight === 'number' ? weight : isObject(weight) && weight[nm] > 0 ? weight[nm] : total / dots;
328
- swarm.crossed[nm] = { up: new Float64Array(nc + 1), down: new Float64Array(nc + 1) };
402
+ const w = typeof weight === 'number' ? weight : isObject(weight) && weight[nm] !== undefined ? weight[nm] : total / dots;
403
+ if (!(w > 0 && Number.isFinite(w))) throw new RangeError(`particles: the weight of ${nm} (mol/m² per dot) must be a positive number, got ${w}` + (total > 0 ? '' : ' (it has nothing to draw)'));
404
+ swarm.weight[nm] = w;
329
405
  }
330
406
  return update(sol);
331
407
  }
package/src/solution.js CHANGED
@@ -228,8 +228,15 @@ export function makeSolution(solver, result = {}) {
228
228
  );
229
229
  }
230
230
  });
231
- const cond = solver.conditioning, un = solver.unreached;
232
- if (result.converged === false && un) {
231
+ const cond = solver.conditioning, un = solver.unreached, ch = solver.charging;
232
+ if (result.converged === false && ch) {
233
+ const A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
234
+ sol.warnings.push(
235
+ `${ch.terminal}: driven at ${ch.current.toPrecision(3)} ${A}, it has no steady state: ` +
236
+ (ch.what.length > 0 ? `what it feeds (${ch.what.join(', ')}) is closed but for it, so it only stores what comes in (a host filling)` : 'it passes current only by charging its capacitance') +
237
+ '. advance() in time instead; at no current, a steady solve keeps the charge it holds.',
238
+ );
239
+ } else if (result.converged === false && un) {
233
240
  const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
234
241
  // The opposite sign within reach: likely a sign slip (a drive is the current into the device).
235
242
  const flipped = Math.min(I0, I1) <= -un.target && -un.target <= Math.max(I0, I1);
@@ -245,13 +252,31 @@ export function makeSolution(solver, result = {}) {
245
252
  );
246
253
  } else if (result.converged === false && cond && cond.digits > 12) {
247
254
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
255
+ // An ion there so scarce beside the others (a minority swept out of a junction by a large
256
+ // bias, excluded at a sharp neutral face) that its level can't be held in double precision.
257
+ let scarce = null;
258
+ for (const g of cond.nodes ?? []) {
259
+ let all = 0;
260
+ for (let i = 0; i < model.species.length; i++) if (model.species[i].z !== 0 && solver.c[g * model.species.length + i] > 0) all += Math.abs(model.species[i].z) * solver.c[g * model.species.length + i];
261
+ for (let i = 0; i < model.species.length; i++) {
262
+ const c = solver.c[g * model.species.length + i], r = c / all;
263
+ if (model.species[i].z !== 0 && c > 0 && r < 1e-10 && !(r >= scarce?.r)) scarce = { r, name: model.species[i].name };
264
+ }
265
+ }
248
266
  sol.warnings.push(
249
- `the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): part of the device is held ` +
250
- 'only weakly, e.g. a floating region coupled to the rest through tiny conductances or rates, or a stiff ' +
251
- 'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).',
267
+ `the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): ` +
268
+ (scarce?.r < 1e-24
269
+ ? `there ${scarce.name} is ${scarce.r.toExponential(0)} of the ions around it, too scarce for its level to be held in double precision ` +
270
+ '(a minority swept out of a junction by a large bias, or excluded at a sharp neutral face). Drive less; or, at a face, ' +
271
+ 'resolve its double layer (ε > 0) rather than make it a sharp neutral step.'
272
+ : 'part of the device is held only weakly, e.g. a floating region coupled to the rest through tiny conductances or rates, or a stiff ' +
273
+ 'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).' +
274
+ (scarce
275
+ ? ` Or a species there too scarce to hold: ${scarce.name} is ${scarce.r.toExponential(0)} of the ions around it (a layer depleted past a limiting current, say), which a smaller drive avoids.`
276
+ : '')),
252
277
  );
253
278
  }
254
- if (result.converged === false && !result.stopped && !un && !(cond && cond.digits > 12)) {
279
+ if (result.converged === false && !result.stopped && !un && !ch && !(cond && cond.digits > 12)) {
255
280
  sol.warnings.push(
256
281
  'the solve did not converge, and nothing specific showed why. ' +
257
282
  'Check the definition with describe() (from driftlet/kit); approach this state in smaller steps (a sweep, or advance() in time); or refine the grid.',
@@ -271,15 +296,127 @@ export function makeSolution(solver, result = {}) {
271
296
  );
272
297
  }
273
298
  };
299
+ // Beside a strictly neutral (ε = 0) material, a face's charge is held in the edge node's box,
300
+ // which then acts as a diffuse layer as wide as the box: a capacitance F²Σz²c·w/RT in series
301
+ // with the face's (a Helmholtz C, or for a pinned face whatever holds the charge on the other
302
+ // side: a conductor's sheet, or a resolved diffuse layer, ε/λ_D), which shrinks as the grid is
303
+ // refined. Flag it where it lowers that capacitance by over 2%. (Where the box is the smaller
304
+ // part, as an ε = 0 metal's against a semiconductor's depletion, it's a fine stand-in for the
305
+ // metal's own screening.)
306
+ const boxed = (g, h, phi, other, where) => {
307
+ const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
308
+ if (mat.conductor || mat.epsr !== 0 || solver.phiUndefined?.[g]) return;
309
+ const s2 = solver.screening(g), Cbox = (F * F * s2 * h) / (2 * RT);
310
+ if (!(s2 > 0)) return;
311
+ let C = phi.C;
312
+ if (phi.type === 'pinned') {
313
+ const om = other < 0 ? null : model.materials[model.regions[grid.nodeRegion[other]].material];
314
+ C = !om || om.conductor ? Infinity : (om.epsr * EPS0) / debye(other);
315
+ }
316
+ if (!(C / (C + Cbox) > 0.02)) return;
317
+ const lowers = Number.isFinite(C) ? `lowering the double-layer capacitance by ${((100 * C) / (C + Cbox)).toFixed(0)}%` : 'which alone sets its charge';
318
+ sol.warnings.push(
319
+ `${where}: the face's charge sits in the strictly neutral cell beside it (${h.toExponential(2)} m), which acts as a diffuse layer that wide ` +
320
+ `(${Cbox.toPrecision(2)} F/m² in series), ${lowers}, so it depends on the mesh. ` +
321
+ (Number.isFinite(C)
322
+ ? `Coarsen the grid there: a cell of ${((2 * 49 * C * RT) / (F * F * s2)).toExponential(1)} m or more keeps it within 2%.`
323
+ : "Give the face a Helmholtz capacitance (phi: { type: 'capacitive', C }), or make it neutral."),
324
+ );
325
+ };
274
326
  interfaces.forEach((itf, f) => {
275
327
  if (itf.phi.type === 'neutral') return;
276
328
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
277
329
  check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
278
330
  check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
331
+ boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`);
332
+ boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`);
279
333
  });
280
334
  const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
281
- if (resolves(contacts.left)) check(0, grid.segLength[0], 'contacts.left');
282
- if (resolves(contacts.right)) check(nNodes - 1, grid.segLength[nNodes - 2], 'contacts.right');
335
+ if (resolves(contacts.left)) {
336
+ check(0, grid.segLength[0], 'contacts.left');
337
+ boxed(0, grid.segLength[0], contacts.left.phi, -1, 'contacts.left');
338
+ }
339
+ if (resolves(contacts.right)) {
340
+ check(nNodes - 1, grid.segLength[nNodes - 2], 'contacts.right');
341
+ boxed(nNodes - 1, grid.segLength[nNodes - 2], contacts.right.phi, -1, 'contacts.right');
342
+ }
343
+ // Electrons or holes above their band's effective density of states (the material's cRef)
344
+ // under Boltzmann statistics: degenerate, where those statistics no longer hold (a surface
345
+ // driven past what a reaction can take away piles holes up to 1e9 mol/m³ and still converges).
346
+ model.regions.forEach((reg, r) => {
347
+ const mat = model.materials[reg.material];
348
+ if (mat.conductor) return;
349
+ model.species.forEach((sp, i) => {
350
+ if ((sp.name !== 'e-' && sp.name !== 'h+') || !mat.present[i] || mat.modelOf?.[i] >= 0) return;
351
+ let top = 0, at = NaN;
352
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
353
+ const c = solver.c[g * model.species.length + i];
354
+ if (c > top) [top, at] = [c, grid.x[g]];
355
+ }
356
+ if (top > mat.cRef[i]) {
357
+ sol.warnings.push(
358
+ `${reg.name}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, above its cRef (${mat.cRef[i].toExponential(2)}, the band's effective density of states), where Boltzmann statistics no longer hold: ` +
359
+ 'give the material Fermi–Dirac statistics (docs/statistics.md), or ask whether this state is physical (a surface driven past what can carry its carriers away).',
360
+ );
361
+ }
362
+ });
363
+ });
364
+ // Any other species denser than any material can be (1e6 mol/m³: water is 5.5e4, a metal's
365
+ // atoms ~1.5e5): a level set where the device can't follow it, such as a contact's for one ion
366
+ // far from its neighbours' (Na⁺ held at 1.2e37 mol/m³, whose currents then read 1e28 A/m²).
367
+ model.regions.forEach((reg, r) => {
368
+ const mat = model.materials[reg.material];
369
+ if (mat.conductor) return;
370
+ model.species.forEach((sp, i) => {
371
+ if (sp.name === 'e-' || sp.name === 'h+' || !mat.present[i]) return;
372
+ let top = 0, at = NaN;
373
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
374
+ const c = solver.c[g * model.species.length + i];
375
+ if (c > top) [top, at] = [c, grid.x[g]];
376
+ }
377
+ if (top > 1e6) {
378
+ sol.warnings.push(
379
+ `${reg.name}: ${sp.name} reaches ${top.toExponential(2)} mol/m³ at x = ${at.toExponential(3)} m, denser than any material: ` +
380
+ 'a level set where the device cannot follow it. Check what holds it there (the V or bath of a contact, its mu0 and cRef) with describe() from driftlet/kit.',
381
+ );
382
+ }
383
+ });
384
+ });
385
+ // A contact that holds its end neutral ('bulk': a bath, an ohmic contact) cuts off any space
386
+ // charge reaching it: a double layer longer than its region. The charge it cuts off is about
387
+ // ρ λ_D at the node beside it; against the region's own space charge (half Σ|ρ| over it, the
388
+ // two signs of a double layer), warn past 1e-3.
389
+ for (const side of ['left', 'right']) {
390
+ if (contacts[side].phi.type !== 'bulk') continue;
391
+ const r = side === 'left' ? 0 : model.regions.length - 1, a = grid.regionStart[r], b = grid.regionEnd[r];
392
+ const mat = model.materials[model.regions[r].material];
393
+ if (!(mat.epsr > 0) || mat.conductor || b - a < 3) continue;
394
+ const n = model.species.length, fixed = model.regions[r].fixedCharge ?? 0;
395
+ const rho = (g) => {
396
+ let q = fixed;
397
+ for (let i = 0; i < n; i++) if (solver.c[g * n + i] > 0) q += F * model.species[i].z * solver.c[g * n + i];
398
+ return q;
399
+ };
400
+ // (and beside the contact the material is itself out of neutrality, by more than round-off:
401
+ // a quasi-neutral layer split off a junction's holds almost no space charge, so the ratio
402
+ // below alone would read its round-off as a cut-off layer)
403
+ const gross = (g) => {
404
+ let q = Math.abs(fixed);
405
+ for (let i = 0; i < n; i++) if (solver.c[g * n + i] > 0) q += F * Math.abs(model.species[i].z) * solver.c[g * n + i];
406
+ return q;
407
+ };
408
+ let total = 0;
409
+ for (let g = a; g <= b; g++) total += (Math.abs(rho(g)) * grid.vol[g]) / 2;
410
+ const g1 = side === 'left' ? a + 1 : b - 1, lam = debye(g1), w = (grid.x[g1 + 1] - grid.x[g1 - 1]) / 2;
411
+ const cut = (Math.abs(rho(g1)) * lam * grid.vol[g1]) / w;
412
+ if (Number.isFinite(cut) && total > 0 && cut > 1e-3 * total && Math.abs(rho(g1)) > 1e-3 * gross(g1)) {
413
+ sol.warnings.push(
414
+ `contacts.${side}: the space charge in ${model.regions[r].name} reaches the contact, which holds its end neutral and so cuts it off (a double layer or depletion longer than the region): ` +
415
+ `the charge beside the contact is ${(cut / total).toPrecision(2)} of the space charge in the region, and the error in that charge can be several times more. ` +
416
+ 'Lengthen the region (a crowded double layer grows as the root of the voltage).',
417
+ );
418
+ }
419
+ }
283
420
 
284
421
  // Steep profiles carrying current: Scharfetter–Gummel takes the field as uniform across each
285
422
  // cell, which fails where it isn't, as at an electrode where a species is nearly depleted (there