driftlet 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
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 holds on average before it's a sea (default ∞)
51
+ * @param {number} [opts.cells] about how many cells across the device (default 120)
52
+ * @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
53
+ */
54
+ export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
55
+ 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
+
58
+ // The lattice: each region's nodes grouped into runs of node boxes about L/cells wide, so a
59
+ // 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];
74
+ }
75
+ }
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;
90
+ }
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));
94
+
95
+ const swarm = {
96
+ cells: lattice.map(({ x0, x1, region }) => ({ x0, x1, region })),
97
+ species: names,
98
+ weight: {}, // mol/m² per dot
99
+ sea: {}, // per species, per cell: 1 where it's a sea
100
+ dots: [], // { species, cell, x }
101
+ // what happened in the last step, beyond hops between cells of dots: { species, kind, x, dir }
102
+ // with kind 'in' (a dot from a sea, contact or metal), 'out' (into one), 'made', 'unmade',
103
+ // or 'cross' (from sea to sea through a face); dir ±1 for a crossing's direction
104
+ events: [],
105
+ crossed: {}, // per species, at each boundary (0 … cells): dots across it, { up, down } (toward +x, −x), counted since you last zeroed them
106
+ time: 0,
107
+ update,
108
+ step,
109
+ expected,
110
+ };
111
+ const S = {}; // per species: amounts, boundaries and rates, from the solution
112
+
113
+ function amounts(sol, nm) {
114
+ const c = sol.c[nm];
115
+ return Float64Array.from(lattice, (cl) => {
116
+ let n = 0;
117
+ for (let g = cl.a; g <= cl.b; g++) if (c[g] > 0) n += c[g] * box[g];
118
+ return n;
119
+ });
120
+ }
121
+
122
+ function fields(sol, nm, prev) {
123
+ const c = sol.c[nm], mu = sol.mu[nm], D = sol.D[nm], J = sol.flux[nm], RT = GAS_CONSTANT * sol.T;
124
+ const N = amounts(sol, nm), w = swarm.weight[nm];
125
+ 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));
127
+ const psi = Float64Array.from(lattice, (cl, K) => mu[cl.mid] / RT - Math.log(N[K] / cl.h));
128
+ // Boundary k: what lies to each side, its one-way fluxes (mol/(m²·s)), and whether it's a face
129
+ // with one-way fluxes of its own (a membrane), whose crossings between seas are events.
130
+ const left = new Uint8Array(nc + 1), right = new Uint8Array(nc + 1), face = new Uint8Array(nc + 1);
131
+ const up = new Float64Array(nc + 1), down = new Float64Array(nc + 1);
132
+ const pair = (k, net, F0) => {
133
+ const r = Math.hypot(F0, net / 2);
134
+ up[k] = r + net / 2;
135
+ down[k] = r - net / 2;
136
+ };
137
+ const side = (K) => (sea[K] ? OUTSIDE : DOT);
138
+ for (let k = 0; k <= nc; k++) {
139
+ const A = k > 0 && on[k - 1] ? lattice[k - 1] : null, B = k < nc && on[k] ? lattice[k] : null;
140
+ if (A && B && A.b + 1 === B.a) {
141
+ // two cells: within a region, or across a face
142
+ if (A.region !== B.region) {
143
+ const itf = sol.interfaces[A.region];
144
+ if (!itf.links[nm]) continue;
145
+ [left[k], right[k]] = [side(k - 1), side(k)];
146
+ if (itf.oneWay?.[nm]) {
147
+ face[k] = 1;
148
+ [up[k], down[k]] = itf.oneWay[nm];
149
+ continue;
150
+ }
151
+ }
152
+ [left[k], right[k]] = [side(k - 1), side(k)];
153
+ const hD = A.h / 2 / D[A.mid] + B.h / 2 / D[B.mid];
154
+ pair(k, J[A.b], (Math.sqrt((N[k - 1] / A.h) * (N[k] / B.h)) * shape(psi[k] - psi[k - 1])) / hD);
155
+ } else if (A || B) {
156
+ // out of the cells: through a contact, or a face to a metal
157
+ const cl = A ?? B, g = A ? A.b : B.a;
158
+ let net, open;
159
+ if (g === 0 || g === nn - 1) {
160
+ const ct = sol.contacts[g === 0 ? 'left' : 'right'];
161
+ open = ct.links[nm] !== undefined;
162
+ net = ct.flux[nm]; // toward +x at either end
163
+ } else {
164
+ open = sol.interfaces[A ? A.region : B.region - 1].links[nm] !== undefined;
165
+ net = J[A ? g : g - 1];
166
+ }
167
+ if (!open) continue;
168
+ [left[k], right[k]] = A ? [side(k - 1), OUTSIDE] : [OUTSIDE, side(k)];
169
+ pair(k, net, (2 * D[g] * Math.max(c[g], 0)) / cl.h);
170
+ }
171
+ }
172
+ // Made and unmade in each cell (mol/(m²·s)): by the bulk reactions' one-way rates (so in
173
+ // equilibrium, generation and recombination both go on, in balance), and whatever else the
174
+ // fluxes don't account for, ∂N/∂t − (J_in − J_out) less the reactions' net (ports, face
175
+ // reactions), with ∂N/∂t from the last solution if time has passed since.
176
+ const made = new Float64Array(nc), unmade = new Float64Array(nc);
177
+ for (const rx of sol.bulkReactions ?? []) {
178
+ const nu = rx.nu?.[nm];
179
+ if (!nu) continue;
180
+ lattice.forEach((cl, K) => {
181
+ for (let g = cl.a; g <= cl.b; g++) {
182
+ if (!(rx.forward[g] >= 0)) continue;
183
+ const fwd = rx.forward[g] * box[g], bwd = (rx.forward[g] - rx.rate[g]) * box[g];
184
+ made[K] += nu > 0 ? nu * fwd : -nu * bwd;
185
+ unmade[K] += nu > 0 ? nu * bwd : -nu * fwd;
186
+ }
187
+ });
188
+ }
189
+ const dt = prev && sol.time > prev.time ? sol.time - prev.time : Infinity;
190
+ for (let K = 0; K < nc; K++) {
191
+ if (!on[K]) continue;
192
+ 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]);
193
+ if (rest > 0) made[K] += rest;
194
+ else unmade[K] -= rest;
195
+ }
196
+ return { N, on, sea, left, right, face, up, down, made, unmade, time: sol.time };
197
+ }
198
+
199
+ /**
200
+ * A new solution: the dots keep their places and move on at its rates. A cell that has become a
201
+ * sea absorbs its dots, and one that's no longer a sea is filled. With resample (a jump between
202
+ * steady states, which has no path in time to follow), each cell's dots are thinned or added to
203
+ * by the change in what it's expected to hold, m → m′: each kept with probability m′/m if it
204
+ * 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.
206
+ */
207
+ function update(next, { resample = false } = {}) {
208
+ sol = next;
209
+ for (const nm of names) {
210
+ const was = S[nm];
211
+ S[nm] = fields(sol, nm, was);
212
+ const F = S[nm], w = swarm.weight[nm];
213
+ swarm.sea[nm] = F.sea;
214
+ const before = (K) => (!was || was.sea[K] || !was.on[K] ? 0 : was.N[K] / w);
215
+ swarm.dots = swarm.dots.filter((d) => {
216
+ if (d.species !== nm) return true;
217
+ if (!F.on[d.cell] || F.sea[d.cell]) return false;
218
+ const m = before(d.cell);
219
+ return !(resample && m > 0) || random() * m < F.N[d.cell] / w;
220
+ });
221
+ F.N.forEach((n, K) => {
222
+ if (!F.on[K] || F.sea[K]) return;
223
+ const m = before(K);
224
+ if (m === 0 || resample) for (let j = poisson(Math.max(0, n / w - m)); j > 0; j--) swarm.dots.push(dot(nm, K));
225
+ });
226
+ }
227
+ return swarm;
228
+ }
229
+
230
+ /** The dots expected in each cell for a species, at the solution's concentrations. */
231
+ function expected(nm) {
232
+ return Float64Array.from(S[nm].N, (n) => n / swarm.weight[nm]);
233
+ }
234
+
235
+ const dot = (nm, K) => ({ species: nm, cell: K, x: lattice[K].x0 + random() * lattice[K].h });
236
+
237
+ // A Poisson count of mean m (a normal one for large m).
238
+ const gauss = () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random());
239
+ function poisson(m) {
240
+ if (m > 50) return Math.max(0, Math.round(m + Math.sqrt(m) * gauss()));
241
+ const e = Math.exp(-m);
242
+ let k = 0;
243
+ for (let p = random(); p > e; p *= random()) k++;
244
+ return k;
245
+ }
246
+
247
+ /**
248
+ * 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.
250
+ */
251
+ function step(dt, { maxHops = 200 } = {}) {
252
+ swarm.events = [];
253
+ // Births first, each at a random moment of the step: made in a cell, or arriving from beyond
254
+ // (a sea, a contact, a metal). Each then moves for the rest of the step. And crossings
255
+ // between seas through a face.
256
+ const born = [];
257
+ for (const nm of names) {
258
+ const F = S[nm], w = swarm.weight[nm], cr = swarm.crossed[nm];
259
+ for (let K = 0; K < nc; K++) {
260
+ if (!F.on[K] || F.sea[K]) continue;
261
+ const made = F.made[K] / w;
262
+ const fromLeft = F.left[K] === OUTSIDE ? F.up[K] / w : 0, fromRight = F.right[K + 1] === OUTSIDE ? F.down[K + 1] / w : 0;
263
+ const rate = made + fromLeft + fromRight;
264
+ if (!(rate > 0)) continue;
265
+ for (let j = poisson(rate * dt); j > 0; j--) {
266
+ const u = random() * rate, d = dot(nm, K);
267
+ if (u < fromLeft) {
268
+ cr.up[K]++;
269
+ swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
270
+ } else if (u < fromLeft + fromRight) {
271
+ cr.down[K + 1]++;
272
+ swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
273
+ } else swarm.events.push({ species: nm, kind: 'made', x: d.x });
274
+ born.push([d, random() * dt]);
275
+ }
276
+ }
277
+ for (let k = 0; k <= nc; k++) {
278
+ if (!F.face[k] || F.left[k] !== OUTSIDE || F.right[k] !== OUTSIDE) continue;
279
+ 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
+ }
284
+ }
285
+ }
286
+ }
287
+ 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);
290
+ swarm.dots = keep;
291
+ swarm.time += dt;
292
+ return swarm;
293
+ }
294
+
295
+ // 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) {
297
+ const F = S[d.species], cr = swarm.crossed[d.species];
298
+ let t = 0;
299
+ for (let hop = 0; hop < maxHops; hop++) {
300
+ const K = d.cell, N = F.N[K];
301
+ if (!(N > 0)) return true;
302
+ 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;
303
+ const total = r + l + die;
304
+ if (!(total > 0)) return true;
305
+ t -= Math.log(1 - random()) / total;
306
+ if (t > dt) return true;
307
+ const u = random() * total;
308
+ if (u >= r + l) {
309
+ swarm.events.push({ species: d.species, kind: 'unmade', x: d.x });
310
+ return false;
311
+ }
312
+ const k = u < r ? K + 1 : K, dir = u < r ? 1 : -1;
313
+ (dir > 0 ? cr.up : cr.down)[k]++;
314
+ if ((dir > 0 ? F.right[k] : F.left[k]) === OUTSIDE) {
315
+ swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
316
+ return false;
317
+ }
318
+ d.cell = K + dir;
319
+ d.x = lattice[d.cell].x0 + random() * lattice[d.cell].h;
320
+ }
321
+ return true;
322
+ }
323
+
324
+ // Weights, then the first dots, placed by the concentration.
325
+ for (const nm of names) {
326
+ 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) };
329
+ }
330
+ return update(sol);
331
+ }
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
- return { rate, regions, total };
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.
@@ -191,7 +215,7 @@ export function makeSolution(solver, result = {}) {
191
215
 
192
216
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
193
217
  // gate contacts), check the local Debye length against the adjacent cell.
194
- sol.warnings = [...(model.warnings ?? [])];
218
+ sol.warnings = [...(model.warnings ?? []), ...(result.stopped ? [result.stopped] : [])];
195
219
  // A surface covered almost completely: its coverages' η barely move anything below ~1e-10 bare,
196
220
  // and Newton loses them (a transient can stall there).
197
221
  sol.ports.forEach((p) => {
@@ -207,9 +231,17 @@ export function makeSolution(solver, result = {}) {
207
231
  const cond = solver.conditioning, un = solver.unreached;
208
232
  if (result.converged === false && un) {
209
233
  const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
234
+ // The opposite sign within reach: likely a sign slip (a drive is the current into the device).
235
+ const flipped = Math.min(I0, I1) <= -un.target && -un.target <= Math.max(I0, I1);
210
236
  sol.warnings.push(
211
237
  `${un.terminal}: no steady state passes the driven current ${g(un.target)} ${A}; held from ${g(V0)} V to ${g(V1)} V ` +
212
- `it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage.`,
238
+ `it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage.` +
239
+ (flipped ? ` (It passes ${g(-un.target)} ${A}: if that was meant, a terminal's current is into the device.)` : '') +
240
+ // Nothing passed at any voltage: the device only stores what comes in (a host filling, a
241
+ // capacitor charging), so it has no steady state at a current.
242
+ (Math.max(Math.abs(I0), Math.abs(I1)) < 1e-6 * Math.abs(un.target)
243
+ ? ' It passes almost nothing steadily at any voltage, so it may only store what comes in (a host filling, a capacitor charging), which has no steady state at a current: advance() in time instead.'
244
+ : ''),
213
245
  );
214
246
  } else if (result.converged === false && cond && cond.digits > 12) {
215
247
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
@@ -219,6 +251,12 @@ export function makeSolution(solver, result = {}) {
219
251
  'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).',
220
252
  );
221
253
  }
254
+ if (result.converged === false && !result.stopped && !un && !(cond && cond.digits > 12)) {
255
+ sol.warnings.push(
256
+ 'the solve did not converge, and nothing specific showed why. ' +
257
+ 'Check the definition with describe() (from driftlet/kit); approach this state in smaller steps (a sweep, or advance() in time); or refine the grid.',
258
+ );
259
+ }
222
260
  const debye = (g) => {
223
261
  const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
224
262
  const s2 = solver.screening(g); // zᵀKz: Σ z² c for ideal statistics