driftlet 0.13.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 +97 -33
- package/docs/kit.md +95 -8
- package/docs/logo-dark.svg +3 -2
- package/docs/logo.svg +3 -2
- package/docs/numerics.md +193 -114
- package/docs/reliability.md +16 -10
- package/docs/statistics.md +28 -4
- package/llms.txt +325 -335
- package/package.json +1 -1
- package/src/blockTridiagonal.js +12 -6
- package/src/checks.js +16 -5
- package/src/describe.js +46 -5
- package/src/device.js +88 -10
- package/src/index.js +19 -7
- package/src/kit.js +1 -0
- package/src/particles.js +407 -0
- package/src/solution.js +171 -10
- package/src/solver.js +797 -262
- package/src/sources.js +10 -2
- package/src/stack.js +16 -6
- package/src/statistics.js +7 -3
- package/types/checks.d.ts +2 -1
- package/types/kit.d.ts +1 -0
- package/types/particles.d.ts +39 -0
- package/types/solution.d.ts +1 -0
- package/types/solver.d.ts +47 -29
- package/types/stack.d.ts +6 -4
package/src/particles.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// Particles: a solution drawn as dots, each a fixed amount of one species, hopping between the
|
|
2
|
+
// cells of a display lattice. The hops' one-way rates are set from the solution so that, on
|
|
3
|
+
// average, the dots' density is its concentration and their net crossing rate is its flux: the
|
|
4
|
+
// dots are a stochastic sample of the drift–diffusion solution, not a separate model. Dots are
|
|
5
|
+
// made and unmade where the flux diverges (reactions, generation, ports), and they enter and
|
|
6
|
+
// leave through the contacts and any metal.
|
|
7
|
+
//
|
|
8
|
+
// Across each cell boundary, the one-way fluxes F⁺ (toward +x) and F⁻ are set by two numbers:
|
|
9
|
+
// their difference, the solution's net flux J there, and their product, F⁺F⁻ = F₀², the
|
|
10
|
+
// boundary's exchange. In equilibrium F⁺ = F⁻ = F₀, detailed balance, so the dots settle into
|
|
11
|
+
// the concentration profile whatever F₀ is; F₀ sets only how fast they jitter. Between cells it's
|
|
12
|
+
// Scharfetter–Gummel's (whose one-way fluxes over a uniform field have F⁺F⁻ fixed by the drift
|
|
13
|
+
// across the boundary): F₀ = (D/h)√(c_K c_K+1)·(d/2)/sinh(d/2), d the step in ψ = μ̄/RT − ln c,
|
|
14
|
+
// which gives a dot in a flat potential the diffusivity D. Across a face that a species crosses
|
|
15
|
+
// by permeability, the one-way fluxes are the face's own (Ussing's unidirectional fluxes), so the
|
|
16
|
+
// dots crossing each way are counted as a tracer experiment would count them.
|
|
17
|
+
//
|
|
18
|
+
// Where a species is dense, dots would crowd: a cell that would hold more than `cap` of them is
|
|
19
|
+
// drawn as a sea instead (its concentration, shaded), which holds no dots but exchanges them with
|
|
20
|
+
// its neighbours as a contact's reservoir does: a dot hopping in is absorbed, and dots come out at
|
|
21
|
+
// the one-way rate. Crossings between two seas through a face (an ion through a membrane between
|
|
22
|
+
// two baths) are events, at the same rates. So a junction shows its majority carriers as seas
|
|
23
|
+
// and its minority carriers as dots, each one injected, drifting, diffusing and recombining.
|
|
24
|
+
|
|
25
|
+
import { GAS_CONSTANT } from './constants.js';
|
|
26
|
+
|
|
27
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
28
|
+
|
|
29
|
+
// (d/2)/sinh(d/2) = √(B(d)B(−d)).
|
|
30
|
+
const shape = (d) => {
|
|
31
|
+
const a = Math.abs(d) / 2;
|
|
32
|
+
if (a < 1e-4) return 1 - (a * a) / 6;
|
|
33
|
+
if (a > 700) return 0;
|
|
34
|
+
return a / Math.sinh(a);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// What lies to one side of a cell boundary, for a species.
|
|
38
|
+
const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea, a contact's reservoir or a metal
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A swarm of dots sampling a solution's concentrations and fluxes. Amounts are per area (a planar
|
|
42
|
+
* device's). `step(dt)` moves them on in the device's time, at the rates of the solution last
|
|
43
|
+
* given; `update(sol)` gives a new one.
|
|
44
|
+
* @param {object} sol a solution (from solve(), advance(), live(), …)
|
|
45
|
+
* @param {object} [opts]
|
|
46
|
+
* @param {string[]} [opts.species] which species (default: every species with a concentration)
|
|
47
|
+
* @param {number} [opts.dots] about how many dots per species (default 200), for the default weight
|
|
48
|
+
* @param {number|object} [opts.weight] amount per dot, mol/m²: one number for every species (so
|
|
49
|
+
* their dots compare), or { name: weight }; default, each species' total over `dots`
|
|
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 ∞)
|
|
52
|
+
* @param {number} [opts.cells] about how many cells across the device (default 120)
|
|
53
|
+
* @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
|
|
54
|
+
*/
|
|
55
|
+
export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
|
|
56
|
+
const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
|
|
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
|
|
62
|
+
|
|
63
|
+
// The lattice: each region's nodes grouped into runs of node boxes about L/cells wide, so a
|
|
64
|
+
// cell boundary is a segment, where the solution's flux is. (A metal's nodes, with no
|
|
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
|
+
}
|
|
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;
|
|
100
|
+
}
|
|
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];
|
|
108
|
+
}
|
|
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]);
|
|
113
|
+
|
|
114
|
+
const swarm = {
|
|
115
|
+
cells: [],
|
|
116
|
+
species: names,
|
|
117
|
+
weight: {}, // mol/m² per dot
|
|
118
|
+
sea: {}, // per species, per cell: 1 where it's a sea
|
|
119
|
+
dots: [], // { species, cell, x }
|
|
120
|
+
// what happened in the last step, beyond hops between cells of dots: { species, kind, x, dir }
|
|
121
|
+
// with kind 'in' (a dot from a sea, contact or metal), 'out' (into one), 'made', 'unmade',
|
|
122
|
+
// or 'cross' (from sea to sea through a face); dir ±1 for a crossing's direction
|
|
123
|
+
events: [],
|
|
124
|
+
crossed: {}, // per species, at each boundary (0 … cells): dots across it, { up, down } (toward +x, −x), counted since you last zeroed them
|
|
125
|
+
time: 0,
|
|
126
|
+
update,
|
|
127
|
+
step,
|
|
128
|
+
expected,
|
|
129
|
+
};
|
|
130
|
+
const S = {}; // per species: amounts, boundaries and rates, from the solution
|
|
131
|
+
|
|
132
|
+
function amounts(sol, nm) {
|
|
133
|
+
const c = sol.c[nm];
|
|
134
|
+
return Float64Array.from(lattice, (cl) => {
|
|
135
|
+
let n = 0;
|
|
136
|
+
for (let g = cl.a; g <= cl.b; g++) if (c[g] > 0) n += c[g] * box[g];
|
|
137
|
+
return n;
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fields(sol, nm, prev) {
|
|
142
|
+
const c = sol.c[nm], mu = sol.mu[nm], D = sol.D[nm], J = sol.flux[nm], RT = GAS_CONSTANT * sol.T;
|
|
143
|
+
const N = amounts(sol, nm), w = swarm.weight[nm];
|
|
144
|
+
const on = Uint8Array.from(lattice, (cl) => (c[cl.mid] >= 0 ? 1 : 0));
|
|
145
|
+
const sea = Uint8Array.from(N, (n, K) => (on[K] && dense(n / w, K) ? 1 : 0));
|
|
146
|
+
const psi = Float64Array.from(lattice, (cl, K) => mu[cl.mid] / RT - Math.log(N[K] / cl.h));
|
|
147
|
+
// Boundary k: what lies to each side, its one-way fluxes (mol/(m²·s)), and whether it's a face
|
|
148
|
+
// with one-way fluxes of its own (a membrane), whose crossings between seas are events.
|
|
149
|
+
const left = new Uint8Array(nc + 1), right = new Uint8Array(nc + 1), face = new Uint8Array(nc + 1);
|
|
150
|
+
const up = new Float64Array(nc + 1), down = new Float64Array(nc + 1);
|
|
151
|
+
const pair = (k, net, F0) => {
|
|
152
|
+
const r = Math.hypot(F0, net / 2);
|
|
153
|
+
up[k] = r + net / 2;
|
|
154
|
+
down[k] = r - net / 2;
|
|
155
|
+
};
|
|
156
|
+
const side = (K) => (sea[K] ? OUTSIDE : DOT);
|
|
157
|
+
for (let k = 0; k <= nc; k++) {
|
|
158
|
+
const A = k > 0 && on[k - 1] ? lattice[k - 1] : null, B = k < nc && on[k] ? lattice[k] : null;
|
|
159
|
+
if (A && B && A.b + 1 === B.a) {
|
|
160
|
+
// two cells: within a region, or across a face
|
|
161
|
+
if (A.region !== B.region) {
|
|
162
|
+
const itf = sol.interfaces[A.region];
|
|
163
|
+
if (!itf.links[nm]) continue;
|
|
164
|
+
[left[k], right[k]] = [side(k - 1), side(k)];
|
|
165
|
+
if (itf.oneWay?.[nm]) {
|
|
166
|
+
face[k] = 1;
|
|
167
|
+
[up[k], down[k]] = itf.oneWay[nm];
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
[left[k], right[k]] = [side(k - 1), side(k)];
|
|
172
|
+
const hD = A.h / 2 / D[A.mid] + B.h / 2 / D[B.mid];
|
|
173
|
+
pair(k, J[A.b], (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD);
|
|
174
|
+
} else if (A || B) {
|
|
175
|
+
// out of the cells: through a contact, or a face to a metal
|
|
176
|
+
const cl = A ?? B, g = A ? A.b : B.a;
|
|
177
|
+
let net, open;
|
|
178
|
+
if (g === 0 || g === nn - 1) {
|
|
179
|
+
const ct = sol.contacts[g === 0 ? 'left' : 'right'];
|
|
180
|
+
open = ct.links[nm] !== undefined;
|
|
181
|
+
net = ct.flux[nm]; // toward +x at either end
|
|
182
|
+
} else {
|
|
183
|
+
open = sol.interfaces[A ? A.region : B.region - 1].links[nm] !== undefined;
|
|
184
|
+
net = J[A ? g : g - 1];
|
|
185
|
+
}
|
|
186
|
+
if (!open) continue;
|
|
187
|
+
[left[k], right[k]] = A ? [side(k - 1), OUTSIDE] : [OUTSIDE, side(k)];
|
|
188
|
+
pair(k, net, (2 * D[g] * Math.max(c[g], 0)) / cl.h);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Made and unmade in each cell (mol/(m²·s)): by the bulk reactions' one-way rates (so in
|
|
192
|
+
// equilibrium, generation and recombination both go on, in balance), and whatever else the
|
|
193
|
+
// fluxes don't account for, ∂N/∂t − (J_in − J_out) less the reactions' net (ports, face
|
|
194
|
+
// reactions), with ∂N/∂t from the last solution if time has passed since.
|
|
195
|
+
const made = new Float64Array(nc), unmade = new Float64Array(nc);
|
|
196
|
+
for (const rx of sol.bulkReactions ?? []) {
|
|
197
|
+
const nu = rx.nu?.[nm];
|
|
198
|
+
if (!nu) continue;
|
|
199
|
+
lattice.forEach((cl, K) => {
|
|
200
|
+
for (let g = cl.a; g <= cl.b; g++) {
|
|
201
|
+
if (!(rx.forward[g] >= 0)) continue;
|
|
202
|
+
const fwd = rx.forward[g] * box[g], bwd = (rx.forward[g] - rx.rate[g]) * box[g];
|
|
203
|
+
made[K] += nu > 0 ? nu * fwd : -nu * bwd;
|
|
204
|
+
unmade[K] += nu > 0 ? nu * bwd : -nu * fwd;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const dt = prev && sol.time > prev.time ? sol.time - prev.time : Infinity;
|
|
209
|
+
for (let K = 0; K < nc; K++) {
|
|
210
|
+
if (!on[K]) continue;
|
|
211
|
+
const rest = (Number.isFinite(dt) ? (N[K] - prev.N[K]) / dt : 0) + up[K + 1] - down[K + 1] - (up[K] - down[K]) - (made[K] - unmade[K]);
|
|
212
|
+
if (rest > 0) made[K] += rest;
|
|
213
|
+
else unmade[K] -= rest;
|
|
214
|
+
}
|
|
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 };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A new solution: the dots keep their places and move on at its rates. A cell that has become a
|
|
222
|
+
* sea absorbs its dots, and one that's no longer a sea is filled. With resample (a jump between
|
|
223
|
+
* steady states, which has no path in time to follow), each cell's dots are thinned or added to
|
|
224
|
+
* by the change in what it's expected to hold, m → m′: each kept with probability m′/m if it
|
|
225
|
+
* falls, Poisson(m′ − m) more if it rises, which takes a Poisson sample of the one into a
|
|
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.
|
|
228
|
+
*/
|
|
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)}`);
|
|
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)`);
|
|
237
|
+
for (const nm of names) {
|
|
238
|
+
const was = S[nm];
|
|
239
|
+
S[nm] = fields(sol, nm, was);
|
|
240
|
+
const F = S[nm], w = swarm.weight[nm];
|
|
241
|
+
swarm.sea[nm] = F.sea;
|
|
242
|
+
const before = (K) => (!was || was.sea[K] || !was.on[K] ? 0 : was.N[K] / w);
|
|
243
|
+
swarm.dots = swarm.dots.filter((d) => {
|
|
244
|
+
if (d.species !== nm) return true;
|
|
245
|
+
if (!F.on[d.cell] || F.sea[d.cell]) return false;
|
|
246
|
+
const m = before(d.cell);
|
|
247
|
+
return !(resample && m > 0) || random() * m < F.N[d.cell] / w;
|
|
248
|
+
});
|
|
249
|
+
F.N.forEach((n, K) => {
|
|
250
|
+
if (!F.on[K] || F.sea[K]) return;
|
|
251
|
+
const m = before(K);
|
|
252
|
+
if (m === 0 || resample) for (let j = poisson(Math.max(0, n / w - m)); j > 0; j--) swarm.dots.push(dot(nm, K));
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return swarm;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The dots expected in each cell for a species, at the solution's concentrations. */
|
|
259
|
+
function expected(nm) {
|
|
260
|
+
if (!S[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in this swarm (${names.join(', ')})`);
|
|
261
|
+
return Float64Array.from(S[nm].N, (n) => n / swarm.weight[nm]);
|
|
262
|
+
}
|
|
263
|
+
|
|
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) });
|
|
277
|
+
|
|
278
|
+
// A Poisson count of mean m (a normal one for large m).
|
|
279
|
+
const gauss = () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random());
|
|
280
|
+
function poisson(m) {
|
|
281
|
+
if (m > 50) return Math.max(0, Math.round(m + Math.sqrt(m) * gauss()));
|
|
282
|
+
const e = Math.exp(-m);
|
|
283
|
+
let k = 0;
|
|
284
|
+
for (let p = random(); p > e; p *= random()) k++;
|
|
285
|
+
return k;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Move the dots on by dt (s, the device's time): each hops, and dots are made and unmade, at
|
|
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.
|
|
295
|
+
*/
|
|
296
|
+
function step(dt, { maxHops = 200 } = {}) {
|
|
297
|
+
// (A first frame or a clock stepping back, NaN or negative, moves nothing.)
|
|
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
|
+
}
|
|
316
|
+
// Births first, each at a random moment of the step: made in a cell, or arriving from beyond
|
|
317
|
+
// (a sea, a contact, a metal). Each then moves for the rest of the step. And crossings
|
|
318
|
+
// between seas: through a face, as events; between cells that are seas only for this step,
|
|
319
|
+
// counted.
|
|
320
|
+
const born = [];
|
|
321
|
+
for (const nm of names) {
|
|
322
|
+
const F = S[nm], w = swarm.weight[nm], cr = swarm.crossed[nm];
|
|
323
|
+
for (let K = 0; K < nc; K++) {
|
|
324
|
+
if (!F.on[K] || F.sea[K]) continue;
|
|
325
|
+
const made = F.made[K] / w;
|
|
326
|
+
const fromLeft = F.left[K] === OUTSIDE ? F.up[K] / w : 0, fromRight = F.right[K + 1] === OUTSIDE ? F.down[K + 1] / w : 0;
|
|
327
|
+
const rate = made + fromLeft + fromRight;
|
|
328
|
+
if (!(rate > 0)) continue;
|
|
329
|
+
for (let j = poisson(rate * dt); j > 0; j--) {
|
|
330
|
+
const u = random() * rate, d = dot(nm, K);
|
|
331
|
+
// (no 'in' from a cell that's a sea only for this step)
|
|
332
|
+
if (u < fromLeft) {
|
|
333
|
+
cr.up[K]++;
|
|
334
|
+
if (!fast[nm][K - 1]) swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
|
|
335
|
+
} else if (u < fromLeft + fromRight) {
|
|
336
|
+
cr.down[K + 1]++;
|
|
337
|
+
if (!fast[nm][K + 1]) swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
|
|
338
|
+
} else swarm.events.push({ species: nm, kind: 'made', x: d.x });
|
|
339
|
+
born.push([d, random() * dt]);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (let k = 0; k <= nc; k++) {
|
|
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;
|
|
346
|
+
for (const [dir, flux, count] of [[1, F.up[k], cr.up], [-1, F.down[k], cr.down]]) {
|
|
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 });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const keep = [];
|
|
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);
|
|
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
|
+
}
|
|
365
|
+
swarm.time += dt;
|
|
366
|
+
return swarm;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// A dot's hops for a time dt (Gillespie's: exact for rates held fixed): whether it's still in.
|
|
370
|
+
function walk(d, dt, maxHops, fast) {
|
|
371
|
+
const F = S[d.species], cr = swarm.crossed[d.species];
|
|
372
|
+
let t = 0;
|
|
373
|
+
for (let hop = 0; hop < maxHops; hop++) {
|
|
374
|
+
const K = d.cell, N = F.N[K];
|
|
375
|
+
if (!(N > 0)) return true;
|
|
376
|
+
const r = F.right[K + 1] ? F.up[K + 1] / N : 0, l = F.left[K] ? F.down[K] / N : 0, die = F.unmade[K] / N;
|
|
377
|
+
const total = r + l + die;
|
|
378
|
+
if (!(total > 0)) return true;
|
|
379
|
+
t -= Math.log(1 - random()) / total;
|
|
380
|
+
if (t > dt) return true;
|
|
381
|
+
const u = random() * total;
|
|
382
|
+
if (u >= r + l) {
|
|
383
|
+
swarm.events.push({ species: d.species, kind: 'unmade', x: d.x });
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const k = u < r ? K + 1 : K, dir = u < r ? 1 : -1;
|
|
387
|
+
(dir > 0 ? cr.up : cr.down)[k]++;
|
|
388
|
+
if ((dir > 0 ? F.right[k] : F.left[k]) === OUTSIDE) {
|
|
389
|
+
if (!fast?.[K + dir]) swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
d.cell = K + dir;
|
|
393
|
+
d.x = place(d.species, d.cell);
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// The lattice and weights, then the first dots, placed by the concentration.
|
|
399
|
+
draw(sol);
|
|
400
|
+
for (const nm of names) {
|
|
401
|
+
const total = amounts(sol, nm).reduce((a, b) => a + b, 0);
|
|
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;
|
|
405
|
+
}
|
|
406
|
+
return update(sol);
|
|
407
|
+
}
|
package/src/solution.js
CHANGED
|
@@ -26,6 +26,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
26
26
|
muStd: {},
|
|
27
27
|
V: {},
|
|
28
28
|
Vstd: {},
|
|
29
|
+
T: model.T,
|
|
29
30
|
time: solver.time,
|
|
30
31
|
steady: solver.atSteady,
|
|
31
32
|
converged: result.converged ?? true,
|
|
@@ -73,6 +74,17 @@ export function makeSolution(solver, result = {}) {
|
|
|
73
74
|
// Contacts: fluxes and displacement at the final state (re-evaluated from the balance rows of
|
|
74
75
|
// the boxes they're read from).
|
|
75
76
|
solver.computeConcentrations();
|
|
77
|
+
// Each species' flux toward +x across each segment (between nodes g and g + 1; a face's across
|
|
78
|
+
// its pair of nodes), and its diffusivity at each node (NaN where it's absent or in a metal).
|
|
79
|
+
sol.flux = {};
|
|
80
|
+
sol.D = {};
|
|
81
|
+
solver.segmentFluxes().forEach((J, i) => (sol.flux[species[i].name] = J));
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
sol.D[species[i].name] = Float64Array.from(grid.nodeRegion, (r, g) => {
|
|
84
|
+
const mat = model.materials[model.regions[r].material];
|
|
85
|
+
return mat.conductor || !solver.present[g * n + i] ? NaN : mat.D[i];
|
|
86
|
+
});
|
|
87
|
+
}
|
|
76
88
|
solver._assembleBookkeeping(solver.lastDt);
|
|
77
89
|
sol.contacts = {};
|
|
78
90
|
sol.gates = {};
|
|
@@ -90,6 +102,8 @@ export function makeSolution(solver, result = {}) {
|
|
|
90
102
|
// Current toward +x through this contact; in steady state both contacts agree.
|
|
91
103
|
const V = solver.termV[side === 'left' ? 0 : 1];
|
|
92
104
|
sol.contacts[side] = { V, flux, D, current: conduction + displacement };
|
|
105
|
+
// How each species crosses it (a link type), for those that do.
|
|
106
|
+
sol.contacts[side].links = Object.fromEntries(ct.species.flatMap((l, i) => (l.type === 'blocked' ? [] : [[species[i].name, l.type]])));
|
|
93
107
|
if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
|
|
94
108
|
// Charge on the gate (or metal) plate, per area if the device is planar: +D at the left, −D
|
|
95
109
|
// at the right.
|
|
@@ -154,6 +168,11 @@ export function makeSolution(solver, result = {}) {
|
|
|
154
168
|
for (let i = 0; i < n; i++) N[species[i].name] = u[b * M + 1 + i];
|
|
155
169
|
const rates = itf.reactions.map((_, k) => u[b * M + 1 + n + k]); // mol/(m²·s), forward
|
|
156
170
|
const out = { left: model.regions[f].name, right: model.regions[f + 1].name, dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
|
|
171
|
+
// How each species crosses it (a link type), for those that do; and for those crossing by
|
|
172
|
+
// permeability, the one-way fluxes [toward +x, toward −x], per area.
|
|
173
|
+
out.links = Object.fromEntries(itf.links.flatMap((l, i) => (l.type === 'blocked' ? [] : [[species[i].name, l.type]])));
|
|
174
|
+
const oneWay = solver.faceOneWay(f);
|
|
175
|
+
if (oneWay.some((w) => w)) out.oneWay = Object.fromEntries(oneWay.flatMap((w, i) => (w ? [[species[i].name, w]] : [])));
|
|
157
176
|
if (itf.gates.length > 0) {
|
|
158
177
|
// Each gate's fraction open, and the voltage across the face that drives them.
|
|
159
178
|
out.gates = Object.fromEntries(itf.gates.map((gate, q) => [gate.name, u[solver._gateSlot(f, q)]]));
|
|
@@ -166,17 +185,22 @@ export function makeSolution(solver, result = {}) {
|
|
|
166
185
|
// and integrated over each region and the device (mol/(m²·s)), by the nodes' control volumes,
|
|
167
186
|
// just as the balances count it.
|
|
168
187
|
sol.bulkReactions = solver.rxs.map((rx) => {
|
|
169
|
-
const rate = new Float64Array(nNodes).fill(NaN), regions = model.regions.map(() => 0);
|
|
188
|
+
const rate = new Float64Array(nNodes).fill(NaN), forward = new Float64Array(nNodes).fill(NaN), regions = model.regions.map(() => 0);
|
|
170
189
|
let total = 0;
|
|
171
190
|
for (let g = 0; g < nNodes; g++) {
|
|
172
191
|
const m = solver.nodeMaterial[g];
|
|
173
192
|
if (!(rx.kf[m] > 0)) continue;
|
|
174
193
|
rate[g] = solver.bulkRate(rx, g);
|
|
194
|
+
forward[g] = solver.bulkForward(rx, g);
|
|
175
195
|
const amount = grid.vol[g] * rate[g];
|
|
176
196
|
regions[grid.nodeRegion[g]] += amount;
|
|
177
197
|
total += amount;
|
|
178
198
|
}
|
|
179
|
-
|
|
199
|
+
// Its two one-way rates are forward and forward − rate; what each makes of each species, per
|
|
200
|
+
// forward reaction: ν, negative for what it consumes.
|
|
201
|
+
const nu = {};
|
|
202
|
+
rx.sp.forEach((i, p) => (nu[species[i].name] = (nu[species[i].name] ?? 0) - rx.nu[p]));
|
|
203
|
+
return { rate, forward, nu, regions, total };
|
|
180
204
|
});
|
|
181
205
|
|
|
182
206
|
// Total charge in the device (space charge plus sheet charges), per area if it's planar.
|
|
@@ -204,8 +228,15 @@ export function makeSolution(solver, result = {}) {
|
|
|
204
228
|
);
|
|
205
229
|
}
|
|
206
230
|
});
|
|
207
|
-
const cond = solver.conditioning, un = solver.unreached;
|
|
208
|
-
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) {
|
|
209
240
|
const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
|
|
210
241
|
// The opposite sign within reach: likely a sign slip (a drive is the current into the device).
|
|
211
242
|
const flipped = Math.min(I0, I1) <= -un.target && -un.target <= Math.max(I0, I1);
|
|
@@ -221,13 +252,31 @@ export function makeSolution(solver, result = {}) {
|
|
|
221
252
|
);
|
|
222
253
|
} else if (result.converged === false && cond && cond.digits > 12) {
|
|
223
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
|
+
}
|
|
224
266
|
sol.warnings.push(
|
|
225
|
-
`the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}):
|
|
226
|
-
|
|
227
|
-
|
|
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
|
+
: '')),
|
|
228
277
|
);
|
|
229
278
|
}
|
|
230
|
-
if (result.converged === false && !result.stopped && !un && !(cond && cond.digits > 12)) {
|
|
279
|
+
if (result.converged === false && !result.stopped && !un && !ch && !(cond && cond.digits > 12)) {
|
|
231
280
|
sol.warnings.push(
|
|
232
281
|
'the solve did not converge, and nothing specific showed why. ' +
|
|
233
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.',
|
|
@@ -247,15 +296,127 @@ export function makeSolution(solver, result = {}) {
|
|
|
247
296
|
);
|
|
248
297
|
}
|
|
249
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
|
+
};
|
|
250
326
|
interfaces.forEach((itf, f) => {
|
|
251
327
|
if (itf.phi.type === 'neutral') return;
|
|
252
328
|
const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
|
|
253
329
|
check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
|
|
254
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)`);
|
|
255
333
|
});
|
|
256
334
|
const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
|
|
257
|
-
if (resolves(contacts.left))
|
|
258
|
-
|
|
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
|
+
}
|
|
259
420
|
|
|
260
421
|
// Steep profiles carrying current: Scharfetter–Gummel takes the field as uniform across each
|
|
261
422
|
// cell, which fails where it isn't, as at an electrode where a species is nearly depleted (there
|