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/README.md +1 -1
- package/docs/alignment.md +6 -3
- package/docs/data.md +3 -1
- package/docs/device.md +87 -28
- package/docs/kit.md +47 -16
- package/docs/numerics.md +193 -114
- package/docs/reliability.md +15 -9
- package/docs/statistics.md +28 -4
- package/llms.txt +68 -38
- package/package.json +1 -1
- package/src/blockTridiagonal.js +12 -6
- package/src/checks.js +11 -4
- package/src/describe.js +46 -5
- package/src/device.js +33 -10
- package/src/index.js +19 -7
- package/src/particles.js +131 -55
- package/src/solution.js +145 -8
- package/src/solver.js +708 -260
- package/src/sources.js +10 -2
- package/src/stack.js +16 -6
- package/src/statistics.js +7 -3
- package/types/particles.d.ts +3 -6
- package/types/solver.d.ts +43 -29
- package/types/stack.d.ts +6 -4
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
92
|
-
//
|
|
93
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 =
|
|
393
|
+
d.x = place(d.species, d.cell);
|
|
320
394
|
}
|
|
321
395
|
return true;
|
|
322
396
|
}
|
|
323
397
|
|
|
324
|
-
//
|
|
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
|
-
|
|
328
|
-
|
|
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 &&
|
|
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}):
|
|
250
|
-
|
|
251
|
-
|
|
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))
|
|
282
|
-
|
|
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
|