driftlet 0.10.0 → 0.11.4
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 +46 -36
- package/docs/device.md +24 -6
- package/docs/numerics.md +180 -21
- package/docs/reliability.md +122 -0
- package/llms.txt +11 -6
- package/package.json +2 -1
- package/src/blockTridiagonal.js +17 -4
- package/src/checks.js +17 -10
- package/src/difference.js +142 -0
- package/src/index.js +22 -6
- package/src/solution.js +8 -2
- package/src/solver.js +964 -125
- package/types/blockTridiagonal.d.ts +9 -1
- package/types/difference.d.ts +30 -0
- package/types/index.d.ts +2 -1
- package/types/solver.d.ts +49 -8
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# How far to trust it
|
|
2
|
+
|
|
3
|
+
A drift–diffusion solver is quick to start and slow to finish. The first version solves a pn
|
|
4
|
+
junction; the trouble comes after, with what real devices ask of it:
|
|
5
|
+
|
|
6
|
+
- concentrations spanning 30 orders of magnitude in one device (majority carriers beside
|
|
7
|
+
minority ones, a salt beside the ions its double layer excludes);
|
|
8
|
+
- double layers a millionth of the device's length, resolved or not;
|
|
9
|
+
- species whose amount is fixed rather than their level (a blocked ion, a floating base, a
|
|
10
|
+
closed cell's salt), which makes the steady equations singular;
|
|
11
|
+
- populations held only by couplings of 1e-17 (an inversion layer fed by ~1e3 minority
|
|
12
|
+
carriers per cm³), which a factorisation loses to round-off;
|
|
13
|
+
- terminals driven by a current, which may be beyond what the device can pass;
|
|
14
|
+
- small-signal currents read from sums whose terms are 1e17 times bigger.
|
|
15
|
+
|
|
16
|
+
Each of these makes a plain Newton solver fail, or worse, converge to the wrong answer. driftlet
|
|
17
|
+
has met them, mostly through the two kinds of testing below, and its numerics are built around
|
|
18
|
+
them ([numerics](numerics.md)). This page says what that testing covers and what it found, and
|
|
19
|
+
what is known not to work.
|
|
20
|
+
|
|
21
|
+
## Correct: the validation suite
|
|
22
|
+
|
|
23
|
+
Every physics feature is checked against an analytic result, or an independent code where
|
|
24
|
+
there isn't one: `npm test`, over 300 tests, each a worked device with its closed form. They include
|
|
25
|
+
Shockley's diode equation, depletion and MOS charge, collection theory under Beer–Lambert light,
|
|
26
|
+
Gouy–Chapman and Kilic–Bazant–Ajdari double layers, Donnan and Teorell–Meyer–Sievers
|
|
27
|
+
partitions, Goldman–Hodgkin–Katz, the limiting current and Butler–Volmer kinetics, the
|
|
28
|
+
Macdonald and finite-length Warburg impedances, Cottrell and spherical diffusion, and the
|
|
29
|
+
second law (the free energy in through the terminals equals what's dissipated, every term
|
|
30
|
+
non-negative). Against other codes: IonMonger's perovskite J–V hysteresis (finite elements,
|
|
31
|
+
Octave), and liquid-junction potentials against JPCalc, LJPcalc and JLJP. The README's
|
|
32
|
+
[validation table](../README.md#validation) lists each with its tolerance.
|
|
33
|
+
|
|
34
|
+
Each solution can check itself too: `check()` from `driftlet/kit` balances every species'
|
|
35
|
+
ledger (what comes in through each terminal, is made or destroyed), the conservation
|
|
36
|
+
bookkeeping of a transient, and the grid (the device solved again on a grid twice as fine).
|
|
37
|
+
|
|
38
|
+
## Robust: random devices
|
|
39
|
+
|
|
40
|
+
The validation suite shows driftlet is right where it converges. Whether it converges, on
|
|
41
|
+
devices nobody tuned it for, is what `npm run stress` tests. It builds random but plausible
|
|
42
|
+
devices in five families:
|
|
43
|
+
|
|
44
|
+
| Family | What's drawn |
|
|
45
|
+
|---|---|
|
|
46
|
+
| Semiconductor stacks | Si, Ge or GaAs; 1–3 layers n, p or intrinsic, doping 1e14–1e19 cm⁻³, 50 nm–10 µm each; no recombination, band-to-band or SRH (τ from 1 ns to 10 µs); sometimes lit; sometimes a Schottky contact |
|
|
47
|
+
| MOS capacitors | Si, Ge or GaAs, doped 1e15–1e18 cm⁻³; oxide 1–20 nm; Au, Al or Pt gate at any flat-band offset; with or without generation |
|
|
48
|
+
| Electrolyte cells | one to three salts from the data library's ions, 0.1 mM–1 M, 1 µm–1 mm; double layers resolved or strictly neutral; bath, reversible or blocking electrodes |
|
|
49
|
+
| Electrodes | Ag, Cu or Zn deposition, or Fe³⁺/Fe²⁺ on platinum, with Butler–Volmer kinetics over four decades of k₀; with or without a supporting salt; against a second electrode or a bath |
|
|
50
|
+
| Liquid junctions | two mixed solutions, 1 µM–1 M, sharp or graded, at open circuit |
|
|
51
|
+
|
|
52
|
+
Each device goes through what a user would do with it: a cold solve at no bias, and one at a
|
|
53
|
+
bias; a warm sweep to that bias; open circuit, where it's lit; a current drive (a fraction of
|
|
54
|
+
what passes at 1 V); a transient after a voltage step; and the impedance about equilibrium,
|
|
55
|
+
from far below its slowest relaxation to 1 GHz. Every result is judged by `check()`, and by what must hold whatever the device:
|
|
56
|
+
levels flat at equilibrium with no current, warm and cold solves agreeing, the current driven
|
|
57
|
+
being the one passed, the impedance passive (to 1e-3 of |Z|) and, at low frequency, equal to
|
|
58
|
+
the steady dI/dV,
|
|
59
|
+
and a MOS capacitance never above its oxide's. Each case is seeded from its family and index, so
|
|
60
|
+
any failure reruns alone (`npm run stress -- semi 79`).
|
|
61
|
+
|
|
62
|
+
At 500 devices per family (about three minutes on a desktop), 11,380 of 11,381 scenarios pass:
|
|
63
|
+
|
|
64
|
+
| Family | Cold at 0 V | Cold at bias | Warm sweep | Open circuit or current | Transient | Impedance |
|
|
65
|
+
|---|---|---|---|---|---|---|
|
|
66
|
+
| Semiconductor stacks | 500/500 | 500/500 | 500/500 | 105/105 | 500/500 | 395/395 |
|
|
67
|
+
| MOS capacitors | 500/500 | 500/500 | 500/500 | | 500/500 | 500/500 |
|
|
68
|
+
| Electrolyte cells | 500/500 | 500/500 | 500/500 | | 500/500 | 381/381 |
|
|
69
|
+
| Electrodes | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
|
|
70
|
+
| Liquid junctions | | | | 500/500 | 499/500 | |
|
|
71
|
+
|
|
72
|
+
(Impedance only where the device has an equilibrium to linearise about; open circuit only for
|
|
73
|
+
lit cells.) The one that fails is among the known limits below: a junction on a grid far too
|
|
74
|
+
coarse for its double layers.
|
|
75
|
+
`bench/stress.json` keeps the summary.
|
|
76
|
+
|
|
77
|
+
Most of what driftlet's numerics do differently began as a failure here: the impedance's
|
|
78
|
+
current read across the quietest cut of the device rather than at the contact, GMRES judged by
|
|
79
|
+
its true residual, blocked species held flat in steady solves, continuations for a current drive
|
|
80
|
+
and for light, and Newton refining its solves where a factorisation loses a mode, with an exact
|
|
81
|
+
$`J \cdot v`$.
|
|
82
|
+
|
|
83
|
+
## Hard cases
|
|
84
|
+
|
|
85
|
+
`npm run hard` keeps a corpus of named cases that were hard once (sharp 3 M | 1 µM junctions on
|
|
86
|
+
short steps, an inversion layer filling over minutes, a solar cell's open circuit on a 0.25 nm
|
|
87
|
+
grid, corroding drops), with their step and factorisation counts against a committed baseline,
|
|
88
|
+
so a change that makes one worse shows.
|
|
89
|
+
|
|
90
|
+
## Known limits
|
|
91
|
+
|
|
92
|
+
- **A population held only through couplings far below double precision** is solved in steady
|
|
93
|
+
state by refining Newton's solves, as far as its own equations resolve it: a bipolar stack's
|
|
94
|
+
floating base at bias (a p⁺ base whose holes are held ~1e14 more weakly than they move within
|
|
95
|
+
it) is, and a closed Fe³⁺/Fe²⁺ cell driven by a current near its limit, where Fe³⁺ falls 20
|
|
96
|
+
orders below Fe²⁺, is found as the held voltage that passes it. Where a solve fails, its warning
|
|
97
|
+
says where it lost its digits (a transient gets there). A region held only through its faces (by
|
|
98
|
+
conductances 1e-24 of its own, or by neighbours conducting 1e20 times less) is solved directly
|
|
99
|
+
through its balance summed over it, where its own fluxes cancel exactly
|
|
100
|
+
([islands](numerics.md#steady-state)): its current comes out right to ~1e-15, where it was
|
|
101
|
+
up to 16× off. A population held that weakly within a region, or fed by a reaction, isn't
|
|
102
|
+
covered, and its solve can still converge to the wrong level; the terminal currents then
|
|
103
|
+
don't add up, which is the thing to check.
|
|
104
|
+
- **The impedance is resolved to about 1e-4 of |Z|** where its solves are hardest: the real
|
|
105
|
+
part of a nearly ideal capacitor (a closed redox cell with almost none of one partner), 1e-7 of
|
|
106
|
+
|Z|, can come out slightly negative. Its DC limit matches the steady dI/dV in every stress case.
|
|
107
|
+
- **Strictly neutral regions on very short steps** lose digits at a neutral face between very
|
|
108
|
+
different solutions (3 M against 1 µM at steps of 1e-11 s).
|
|
109
|
+
- **An unresolved double layer** (a cell coarser than the Debye length) gives a charge that
|
|
110
|
+
depends on the grid, and in a sharp junction between very different solutions can stop a
|
|
111
|
+
transient; the solution warns. So does a steep profile on too coarse a cell.
|
|
112
|
+
- **A surface covered to a bare fraction below ~1e-10** barely affects anything any more, and
|
|
113
|
+
the solver struggles with it (the hard corpus's passivating drop without a double layer stops
|
|
114
|
+
partway through its hour); the solution warns, and suggests a less stable film.
|
|
115
|
+
- **Currents below ~0.1 nA/m²** (a GaAs junction's leakage without recombination) aren't resolved
|
|
116
|
+
between solves; such a current is zero for any practical purpose.
|
|
117
|
+
- **A failed solve says so:** `converged: false`, with a warning naming where the system lost
|
|
118
|
+
its digits, or which current no voltage reaches. In these tests, every steady state and
|
|
119
|
+
transient reported converged has passed `check()` and the invariants above.
|
|
120
|
+
- What driftlet doesn't model at all (more than one dimension, cross-diffusion, heat, optics,
|
|
121
|
+
field-dependent mobility) is in the [README](../README.md#what-it-doesnt-do), and open
|
|
122
|
+
numerical work in the [roadmap](../ROADMAP.md).
|
package/llms.txt
CHANGED
|
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
|
|
|
14
14
|
From a CDN, pin the version:
|
|
15
15
|
|
|
16
16
|
```js nocheck
|
|
17
|
-
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
18
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
19
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
17
|
+
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/index.js';
|
|
18
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/kit.js';
|
|
19
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/plot.js';
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
|
|
@@ -346,6 +346,11 @@ for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
|
|
|
346
346
|
}
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
+
To sweep the gate on one device instead, patch the port by name: `dev.set({ ports: { gate: { V } } })`
|
|
350
|
+
(a drive only, so it's cheap and keeps the state as a warm start). An organic electrochemical
|
|
351
|
+
transistor is the same with a volumetric capacitance C*: `area: 1 / t, capacitance: { C: Cstar * t }`
|
|
352
|
+
(docs/device.md, "A capacitance through a window").
|
|
353
|
+
|
|
349
354
|
## Template: a transient, with a pulse injected sideways
|
|
350
355
|
|
|
351
356
|
A **port** feeds a window of nodes inside a region from the side (a point contact, an injector,
|
|
@@ -418,9 +423,9 @@ solve), as long as it settles rather than ping-pongs.
|
|
|
418
423
|
<span id="readout"></span>
|
|
419
424
|
<div id="figure"></div>
|
|
420
425
|
<script type="module">
|
|
421
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
422
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
423
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.
|
|
426
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/index.js';
|
|
427
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/kit.js';
|
|
428
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/plot.js';
|
|
424
429
|
|
|
425
430
|
const def = build({
|
|
426
431
|
T: 300,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driftlet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.4",
|
|
4
4
|
"description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"test": "node --test",
|
|
31
31
|
"bench": "node bench/bench.js",
|
|
32
32
|
"hard": "node bench/hard.js",
|
|
33
|
+
"stress": "node bench/stress.js",
|
|
33
34
|
"smoke": "node tools/smoke.js",
|
|
34
35
|
"shots": "node tools/shots.js",
|
|
35
36
|
"prepublishOnly": "npm test && npm run types",
|
package/src/blockTridiagonal.js
CHANGED
|
@@ -74,6 +74,14 @@ export class BlockTridiagonal {
|
|
|
74
74
|
this.minPivotRatio = Infinity;
|
|
75
75
|
/** The block found exactly singular by the last factor(), or −1. */
|
|
76
76
|
this.singularBlock = -1;
|
|
77
|
+
/**
|
|
78
|
+
* Static pivoting: an exactly zero pivot (all its candidates cancelled to nothing) is
|
|
79
|
+
* replaced by 1e-15 of its block's largest entry rather than thrown on, for a caller that
|
|
80
|
+
* refines its solves (and counted in `perturbedPivots`).
|
|
81
|
+
*/
|
|
82
|
+
this.staticPivots = false;
|
|
83
|
+
/** How many pivots the last factor() replaced. */
|
|
84
|
+
this.perturbedPivots = 0;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
/** Zero all of A, B, C (ready for fresh assembly). */
|
|
@@ -86,13 +94,14 @@ export class BlockTridiagonal {
|
|
|
86
94
|
|
|
87
95
|
/**
|
|
88
96
|
* Factorise the current A, B, C. A, B, C are left untouched.
|
|
89
|
-
* Throws if a diagonal block is exactly singular.
|
|
97
|
+
* Throws if a diagonal block is exactly singular (unless `staticPivots`).
|
|
90
98
|
*/
|
|
91
99
|
factor() {
|
|
92
100
|
const { n, A, B, C, sizes, offA, offB, offC, offX } = this;
|
|
93
101
|
const lu = this._lu, piv = this._piv, cp = this._cp, tmp = this._tmp;
|
|
94
102
|
let minRatio = Infinity;
|
|
95
103
|
this.singularBlock = -1;
|
|
104
|
+
this.perturbedPivots = 0;
|
|
96
105
|
|
|
97
106
|
for (let i = 0; i < n; i++) {
|
|
98
107
|
const m = sizes[i], o = offB[i];
|
|
@@ -110,7 +119,7 @@ export class BlockTridiagonal {
|
|
|
110
119
|
}
|
|
111
120
|
}
|
|
112
121
|
}
|
|
113
|
-
const ratio = luFactor(lu, o, piv, offX[i], m);
|
|
122
|
+
const ratio = luFactor(lu, o, piv, offX[i], m, this);
|
|
114
123
|
if (!(ratio > 0)) {
|
|
115
124
|
this.factored = false;
|
|
116
125
|
this.singularBlock = i;
|
|
@@ -244,7 +253,7 @@ export class BlockTridiagonal {
|
|
|
244
253
|
|
|
245
254
|
// In-place LU with partial pivoting of the m×m block at a[o…]. Row swaps are recorded in
|
|
246
255
|
// piv[p…]. Returns min |pivot| / max |entry| (0 if singular).
|
|
247
|
-
function luFactor(a, o, piv, p, m) {
|
|
256
|
+
function luFactor(a, o, piv, p, m, perturb = null) {
|
|
248
257
|
let maxEntry = 0;
|
|
249
258
|
for (let k = 0; k < m * m; k++) {
|
|
250
259
|
const v = Math.abs(a[o + k]);
|
|
@@ -259,7 +268,11 @@ function luFactor(a, o, piv, p, m) {
|
|
|
259
268
|
if (v > bestVal) { best = r; bestVal = v; }
|
|
260
269
|
}
|
|
261
270
|
piv[p + k] = best;
|
|
262
|
-
if (bestVal === 0)
|
|
271
|
+
if (bestVal === 0) {
|
|
272
|
+
if (!perturb?.staticPivots) return 0;
|
|
273
|
+
bestVal = a[o + best * m + k] = 1e-15 * maxEntry;
|
|
274
|
+
perturb.perturbedPivots++;
|
|
275
|
+
}
|
|
263
276
|
if (bestVal < minPivot) minPivot = bestVal;
|
|
264
277
|
if (best !== k) {
|
|
265
278
|
for (let c = 0; c < m; c++) {
|
package/src/checks.js
CHANGED
|
@@ -96,7 +96,9 @@ function balance(device, sol) {
|
|
|
96
96
|
home[i][r] = ledgers.length - 1;
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
|
-
const
|
|
99
|
+
const partners = new Map(); // a reaction term's species: its net rate is round-off against any of theirs
|
|
100
|
+
const add = (i, r, what, rate, species) => {
|
|
101
|
+
if (species) partners.set(what, species);
|
|
100
102
|
const l = home[i][r];
|
|
101
103
|
if (!(l >= 0 && rate !== 0 && Number.isFinite(rate))) return;
|
|
102
104
|
const same = ledgers[l].terms.find((t) => t.what === what); // a bulk reaction, region by region
|
|
@@ -112,8 +114,9 @@ function balance(device, sol) {
|
|
|
112
114
|
model.reactions.forEach((rx, k) => {
|
|
113
115
|
const what = label(def.bulkReactions?.[k]);
|
|
114
116
|
sol.bulkReactions[k].regions.forEach((total, r) => {
|
|
115
|
-
|
|
116
|
-
for (const { i, nu } of rx.
|
|
117
|
+
const all = [...rx.reactants, ...rx.products].map((p) => p.i);
|
|
118
|
+
for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total, all);
|
|
119
|
+
for (const { i, nu } of rx.products) add(i, r, what, nu * total, all);
|
|
117
120
|
});
|
|
118
121
|
});
|
|
119
122
|
interfaces.forEach((itf, f) => {
|
|
@@ -129,7 +132,8 @@ function balance(device, sol) {
|
|
|
129
132
|
});
|
|
130
133
|
itf.reactions.forEach((rx, k) => {
|
|
131
134
|
const rate = Af * sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
|
|
132
|
-
|
|
135
|
+
const all = rx.part.map((p) => p.i);
|
|
136
|
+
for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate, all);
|
|
133
137
|
});
|
|
134
138
|
});
|
|
135
139
|
// A species in one compartment is named alone; one in several, by where.
|
|
@@ -140,10 +144,12 @@ function balance(device, sol) {
|
|
|
140
144
|
l.title = several[l.i] ? `${l.species} in ${l.compartment}` : l.species;
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
// A species at rest (nothing beyond round-off moving, against what it
|
|
147
|
+
// A species at rest (nothing beyond round-off moving, against what it or its reactions' partners
|
|
148
|
+
// could carry) isn't reported.
|
|
144
149
|
const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
|
|
145
|
-
const natural = naturalScales(device, sol);
|
|
146
|
-
const
|
|
150
|
+
const natural = naturalScales(device, sol), inSolution = naturalScales(device, sol, false); // (partners' scale: not a metal's conductance)
|
|
151
|
+
const scale = (l) => Math.max(natural[l.i], ...l.terms.flatMap((t) => (partners.get(t.what) ?? []).map((j) => inSolution[j])));
|
|
152
|
+
const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l));
|
|
147
153
|
let worst = 0;
|
|
148
154
|
const lines = [];
|
|
149
155
|
for (const l of moving) {
|
|
@@ -167,8 +173,9 @@ function balance(device, sol) {
|
|
|
167
173
|
|
|
168
174
|
// What each species could carry, mol/(m²·s): by transport, D·c/L in each region (a metal's
|
|
169
175
|
// carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
|
|
170
|
-
// flows are round-off of these.
|
|
171
|
-
|
|
176
|
+
// flows are round-off of these. Without `conductors`, transport through conductor regions isn't
|
|
177
|
+
// counted.
|
|
178
|
+
function naturalScales(device, sol, conductors = true) {
|
|
172
179
|
const { model, solver } = device, { species, grid } = model;
|
|
173
180
|
// (totals through the cross-section, as the ledgers are: its largest in each region)
|
|
174
181
|
const area = model.regions.map((_, r) => Math.max(...grid.area.subarray(grid.regionStart[r], grid.regionEnd[r] + 1)));
|
|
@@ -177,7 +184,7 @@ function naturalScales(device, sol) {
|
|
|
177
184
|
model.regions.forEach((reg, r) => {
|
|
178
185
|
const mat = model.materials[reg.material], L = reg.length;
|
|
179
186
|
if (mat.conductor) {
|
|
180
|
-
if (mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
|
|
187
|
+
if (conductors && mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
|
|
181
188
|
return;
|
|
182
189
|
}
|
|
183
190
|
if (!mat.present[i] || !(mat.D[i] > 0)) return;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The dilute kernels' Jacobian terms kept in difference form, for an exact J·v.
|
|
2
|
+
//
|
|
3
|
+
// Assembled, a Scharfetter–Gummel flux's dependence on η at its two ends is two entries, G and
|
|
4
|
+
// −G(1 + E), that cancel for a nearly uniform v; where G is huge (an inversion layer's 0.1 nm
|
|
5
|
+
// cells), J·v loses the flux to round-off. Kept as terms, each is evaluated on differences first:
|
|
6
|
+
// δN = a·(δη_L − z δφ̂_L) + b·(δη_R − δη_L) + d·(δφ̂_R − δφ̂_L),
|
|
7
|
+
// with a = −G E (through c_L), b = −G(1 + E) and d = z ∂N/∂Δ (through the drift). A node's
|
|
8
|
+
// storage and space charge depend on δη_i − z_i δφ̂ alone; the displacement on δφ̂_R − δφ̂_L. A
|
|
9
|
+
// uniform shift of a region (z_i s on each η_i, s on φ̂) then gives exactly zero, as it must.
|
|
10
|
+
//
|
|
11
|
+
// Indices are compact (the solver's rix), with the sink (index N) for an unknown that isn't one:
|
|
12
|
+
// vectors passed in are read there as zero.
|
|
13
|
+
export class DifferenceTerms {
|
|
14
|
+
constructor(N, nodes, segments) {
|
|
15
|
+
this.N = N;
|
|
16
|
+
this.dead = new Uint8Array(N + 1); // rows replaced since (a contact's level, a held stretch)
|
|
17
|
+
this.nI = new Int32Array(nodes); // node term: η column (= its balance row), φ̂ column (= Gauss row)
|
|
18
|
+
this.nP = new Int32Array(nodes);
|
|
19
|
+
this.nZ = new Float64Array(nodes);
|
|
20
|
+
this.nMass = new Float64Array(nodes); // storage per unit (δη − z δφ̂), times 1/dt (or iω)
|
|
21
|
+
this.nCharge = new Float64Array(nodes); // into the Gauss row, per unit (δη − z δφ̂)
|
|
22
|
+
this.sI = new Int32Array(4 * segments); // segment term: η_L, η_R, φ̂_L, φ̂_R
|
|
23
|
+
this.sZ = new Float64Array(segments);
|
|
24
|
+
this.sC = new Float64Array(3 * segments); // a, b, d
|
|
25
|
+
this.dI = new Int32Array(2 * segments); // displacement: φ̂_L, φ̂_R
|
|
26
|
+
this.dK = new Float64Array(segments);
|
|
27
|
+
this.combos = []; // [rk, ri, w]: row rk += w × row ri (strictly neutral nodes' charge rows)
|
|
28
|
+
this.t = new Float64Array(N + 1);
|
|
29
|
+
this.x = new Float64Array(N + 1);
|
|
30
|
+
this.reset();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Afresh for an assembly; `rate` multiplies storage in captured rows (1/dt).
|
|
34
|
+
reset(rate = 0) {
|
|
35
|
+
this.rate = rate;
|
|
36
|
+
this.nn = this.ns = this.nd = 0;
|
|
37
|
+
this.dead.fill(0);
|
|
38
|
+
this.combos.length = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
node(i, p, z, mass, charge) {
|
|
42
|
+
const k = this.nn++;
|
|
43
|
+
this.nI[k] = i;
|
|
44
|
+
this.nP[k] = p;
|
|
45
|
+
this.nZ[k] = z;
|
|
46
|
+
this.nMass[k] = mass;
|
|
47
|
+
this.nCharge[k] = charge;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
segment(iL, iR, pL, pR, z, a, b, d) {
|
|
51
|
+
const k = this.ns++;
|
|
52
|
+
this.sI[4 * k] = iL;
|
|
53
|
+
this.sI[4 * k + 1] = iR;
|
|
54
|
+
this.sI[4 * k + 2] = pL;
|
|
55
|
+
this.sI[4 * k + 3] = pR;
|
|
56
|
+
this.sZ[k] = z;
|
|
57
|
+
this.sC[3 * k] = a;
|
|
58
|
+
this.sC[3 * k + 1] = b;
|
|
59
|
+
this.sC[3 * k + 2] = d;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
displacement(pL, pR, k) {
|
|
63
|
+
const q = this.nd++;
|
|
64
|
+
this.dI[2 * q] = pL;
|
|
65
|
+
this.dI[2 * q + 1] = pR;
|
|
66
|
+
this.dK[q] = k;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
kill(row) {
|
|
70
|
+
this.dead[row] = 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
combine(rk, ri, w) {
|
|
74
|
+
this.combos.push([rk, ri, w]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// into[col] += w × row's coefficients, the steady ones plus `mass` times the storage's (a row
|
|
78
|
+
// captured as plain entries: a contact's current, read before its row is replaced).
|
|
79
|
+
capture(row, w, mass, into) {
|
|
80
|
+
const N = this.N, add = (col, v) => {
|
|
81
|
+
if (col < N && v !== 0) into[col] += w * v;
|
|
82
|
+
};
|
|
83
|
+
for (let k = 0; k < this.nn; k++) {
|
|
84
|
+
const i = this.nI[k], p = this.nP[k], z = this.nZ[k];
|
|
85
|
+
let f = 0;
|
|
86
|
+
if (row === i) f += mass * this.nMass[k];
|
|
87
|
+
if (row === p) f += this.nCharge[k];
|
|
88
|
+
if (f === 0) continue;
|
|
89
|
+
add(i, f);
|
|
90
|
+
add(p, -z * f);
|
|
91
|
+
}
|
|
92
|
+
for (let k = 0; k < this.ns; k++) {
|
|
93
|
+
const iL = this.sI[4 * k], iR = this.sI[4 * k + 1];
|
|
94
|
+
const s = row === iL ? 1 : row === iR ? -1 : 0;
|
|
95
|
+
if (s === 0) continue;
|
|
96
|
+
const z = this.sZ[k], a = this.sC[3 * k], b = this.sC[3 * k + 1], d = this.sC[3 * k + 2];
|
|
97
|
+
add(iL, s * (a - b));
|
|
98
|
+
add(iR, s * b);
|
|
99
|
+
add(this.sI[4 * k + 2], s * (-z * a - d));
|
|
100
|
+
add(this.sI[4 * k + 3], s * d);
|
|
101
|
+
}
|
|
102
|
+
for (let q = 0; q < this.nd; q++) {
|
|
103
|
+
const pL = this.dI[2 * q], pR = this.dI[2 * q + 1], s = row === pL ? 1 : row === pR ? -1 : 0;
|
|
104
|
+
if (s === 0) continue;
|
|
105
|
+
add(pL, s * this.dK[q]);
|
|
106
|
+
add(pR, -s * this.dK[q]);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// out += (steady terms) × v + mass × (storage terms) × v, over the live rows. v is read over
|
|
111
|
+
// its first N entries; transformed (the solver's charge rows), it's mapped back by the caller.
|
|
112
|
+
apply(v, out, steady = 1, mass = 0) {
|
|
113
|
+
const { N, t, x } = this;
|
|
114
|
+
for (let j = 0; j < N; j++) x[j] = v[j];
|
|
115
|
+
x[N] = 0;
|
|
116
|
+
t.fill(0);
|
|
117
|
+
const { nI, nP, nZ, nMass, nCharge } = this;
|
|
118
|
+
for (let k = 0; k < this.nn; k++) {
|
|
119
|
+
const i = nI[k], p = nP[k], w = x[i] - nZ[k] * x[p];
|
|
120
|
+
t[i] += mass * nMass[k] * w;
|
|
121
|
+
t[p] += steady * nCharge[k] * w;
|
|
122
|
+
}
|
|
123
|
+
if (steady !== 0) {
|
|
124
|
+
const { sI, sZ, sC, dI, dK } = this;
|
|
125
|
+
for (let k = 0; k < this.ns; k++) {
|
|
126
|
+
const iL = sI[4 * k], iR = sI[4 * k + 1], pL = sI[4 * k + 2], pR = sI[4 * k + 3];
|
|
127
|
+
const dN = steady * (sC[3 * k] * (x[iL] - sZ[k] * x[pL]) + sC[3 * k + 1] * (x[iR] - x[iL]) + sC[3 * k + 2] * (x[pR] - x[pL]));
|
|
128
|
+
t[iL] += dN;
|
|
129
|
+
t[iR] -= dN;
|
|
130
|
+
}
|
|
131
|
+
for (let q = 0; q < this.nd; q++) {
|
|
132
|
+
const pL = dI[2 * q], pR = dI[2 * q + 1], dD = -steady * dK[q] * (x[pR] - x[pL]);
|
|
133
|
+
t[pL] += dD;
|
|
134
|
+
t[pR] -= dD;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (let j = 0; j < N; j++) if (this.dead[j]) t[j] = 0;
|
|
138
|
+
for (const [rk, ri, w] of this.combos) t[rk] += w * t[ri];
|
|
139
|
+
for (let j = 0; j < N; j++) out[j] += t[j];
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// driftlet: 1D drift–diffusion–reaction solver.
|
|
2
2
|
|
|
3
|
-
import { normalizeDevice } from './device.js';
|
|
3
|
+
import { normalizeDevice, DeviceError } from './device.js';
|
|
4
4
|
import { Solver, SolverError } from './solver.js';
|
|
5
5
|
import { makeSolution } from './solution.js';
|
|
6
6
|
import { merge } from './merge.js';
|
|
@@ -64,20 +64,32 @@ export class Device {
|
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
66
|
* Change part of the definition (deep-merged). A contact given V or I drops the other, so
|
|
67
|
-
* `{ contacts: { right: { I: 0 } } }` switches it to open circuit.
|
|
67
|
+
* `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
|
|
68
|
+
* `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
|
|
68
69
|
* drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
|
|
69
70
|
* the warm start when the grid and species are unchanged (else restarting from the regions' c0).
|
|
70
71
|
* @param {object} patch a partial device definition, merged into the current one
|
|
71
72
|
* @returns {this}
|
|
72
73
|
*/
|
|
73
74
|
set(patch) {
|
|
74
|
-
// A
|
|
75
|
+
// A terminal given a new kind of drive drops the old one: { I: 0 } replaces a held V.
|
|
76
|
+
const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
|
|
75
77
|
for (const side of ['left', 'right']) {
|
|
76
78
|
const c = patch?.contacts?.[side];
|
|
77
|
-
if (c && typeof c === 'object') {
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
if (c && typeof c === 'object') patch = { ...patch, contacts: { ...patch.contacts, [side]: redrive(c) } };
|
|
80
|
+
}
|
|
81
|
+
// Ports: an array replaces them all; an object patches them by name, { gate: { V: 0.2 } }.
|
|
82
|
+
const ports = patch?.ports;
|
|
83
|
+
if (ports !== undefined && !Array.isArray(ports)) {
|
|
84
|
+
if (ports === null || typeof ports !== 'object') throw new DeviceError('set: ports must be an array (replacing them all) or an object of patches by port name, e.g. { gate: { V: 0.2 } }');
|
|
85
|
+
const old = this.def.ports ?? [];
|
|
86
|
+
for (const name of Object.keys(ports)) {
|
|
87
|
+
if (!old.some((p, k) => (p.name ?? `ports[${k}]`) === name)) throw new DeviceError(`set: ports.${name}: no port named '${name}'${old.length ? ` (the ports: ${old.map((p, k) => p.name ?? `ports[${k}]`).join(', ')})` : ''}`);
|
|
80
88
|
}
|
|
89
|
+
patch = { ...patch, ports: old.map((p, k) => {
|
|
90
|
+
const q = ports[p.name ?? `ports[${k}]`];
|
|
91
|
+
return q === undefined ? p : merge(p, redrive(q));
|
|
92
|
+
}) };
|
|
81
93
|
}
|
|
82
94
|
const def = merge(this.def, patch);
|
|
83
95
|
// Only the terminals' drives changed: update them in place, keeping the solver and its state
|
|
@@ -97,6 +109,10 @@ export class Device {
|
|
|
97
109
|
if (old && old.u.length === solver.u.length && old.n === solver.n) {
|
|
98
110
|
solver.u.set(old.u);
|
|
99
111
|
solver.uLo.set(old.uLo);
|
|
112
|
+
// A node newly on an electrode's surface (a window moved, a surface added) starts at its
|
|
113
|
+
// starting coverages, not at whatever its slots held.
|
|
114
|
+
const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
|
|
115
|
+
for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
|
|
100
116
|
solver.computeConcentrations();
|
|
101
117
|
solver.time = old.time;
|
|
102
118
|
solver.contactDEnd = old.contactDEnd;
|
package/src/solution.js
CHANGED
|
@@ -193,8 +193,14 @@ export function makeSolution(solver, result = {}) {
|
|
|
193
193
|
);
|
|
194
194
|
}
|
|
195
195
|
});
|
|
196
|
-
const cond = solver.conditioning;
|
|
197
|
-
if (result.converged === false &&
|
|
196
|
+
const cond = solver.conditioning, un = solver.unreached;
|
|
197
|
+
if (result.converged === false && un) {
|
|
198
|
+
const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
|
|
199
|
+
sol.warnings.push(
|
|
200
|
+
`${un.terminal}: no steady state passes the driven current ${g(un.target)} ${A}; held from ${g(V0)} V to ${g(V1)} V ` +
|
|
201
|
+
`it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage.`,
|
|
202
|
+
);
|
|
203
|
+
} else if (result.converged === false && cond && cond.digits > 12) {
|
|
198
204
|
const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
|
|
199
205
|
sol.warnings.push(
|
|
200
206
|
`the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): part of the device is held ` +
|