driftlet 0.1.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.
- package/LICENSE +14 -0
- package/README.md +285 -0
- package/llms.txt +299 -0
- package/package.json +54 -0
- package/src/bernoulli.js +29 -0
- package/src/blockTridiagonal.js +492 -0
- package/src/constants.js +36 -0
- package/src/data.js +127 -0
- package/src/describe.js +190 -0
- package/src/device.js +954 -0
- package/src/equation.js +188 -0
- package/src/errors.js +8 -0
- package/src/fermi.js +106 -0
- package/src/grid.js +111 -0
- package/src/index.js +207 -0
- package/src/kit.js +76 -0
- package/src/live.js +217 -0
- package/src/merge.js +11 -0
- package/src/plot.js +267 -0
- package/src/solution.js +246 -0
- package/src/solver.js +2884 -0
- package/src/stack.js +172 -0
- package/src/statistics.js +593 -0
- package/src/traces.js +125 -0
- package/src/types.js +221 -0
- package/src/worker.js +24 -0
- package/types/bernoulli.d.ts +4 -0
- package/types/blockTridiagonal.d.ts +92 -0
- package/types/constants.d.ts +33 -0
- package/types/data.d.ts +73 -0
- package/types/describe.d.ts +15 -0
- package/types/device.d.ts +20 -0
- package/types/equation.d.ts +102 -0
- package/types/errors.d.ts +3 -0
- package/types/fermi.d.ts +3 -0
- package/types/grid.d.ts +28 -0
- package/types/index.d.ts +103 -0
- package/types/kit.d.ts +61 -0
- package/types/live.d.ts +101 -0
- package/types/merge.d.ts +1 -0
- package/types/plot.d.ts +60 -0
- package/types/solution.d.ts +18 -0
- package/types/solver.d.ts +342 -0
- package/types/stack.d.ts +59 -0
- package/types/statistics.d.ts +10 -0
- package/types/traces.d.ts +118 -0
- package/types/types.d.ts +574 -0
package/src/solution.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Plotting-ready snapshot of a solver state.
|
|
2
|
+
//
|
|
3
|
+
// Arrays are indexed by grid node; doubled interface nodes appear twice at the same x, so
|
|
4
|
+
// steps plot as vertical lines. Quantities of a species are NaN where it is absent, and
|
|
5
|
+
// the voltage views are NaN for neutral species.
|
|
6
|
+
|
|
7
|
+
import { EPS0, FARADAY } from './constants.js';
|
|
8
|
+
|
|
9
|
+
export function makeSolution(solver, result = {}) {
|
|
10
|
+
const { model, n, M, u, c, VT } = solver;
|
|
11
|
+
const { grid, species, contacts, interfaces } = model;
|
|
12
|
+
const nNodes = grid.nNodes;
|
|
13
|
+
const RT = model.RT, F = FARADAY;
|
|
14
|
+
|
|
15
|
+
const phi = new Float64Array(nNodes);
|
|
16
|
+
for (let g = 0; g < nNodes; g++) phi[g] = solver.phiUndefined[g] ? NaN : VT * u[solver.blockOfNode[g] * M];
|
|
17
|
+
|
|
18
|
+
const sol = {
|
|
19
|
+
x: Float64Array.from(grid.x),
|
|
20
|
+
region: Int32Array.from(grid.nodeRegion),
|
|
21
|
+
species: species.map(({ name, z }) => ({ name, z })),
|
|
22
|
+
regions: model.regions.map((r, k) => ({ name: r.name, material: model.materials[r.material].name, x0: grid.x[grid.regionStart[k]], x1: grid.x[grid.regionEnd[k]] })),
|
|
23
|
+
phi,
|
|
24
|
+
c: {},
|
|
25
|
+
mu: {},
|
|
26
|
+
muStd: {},
|
|
27
|
+
V: {},
|
|
28
|
+
Vstd: {},
|
|
29
|
+
time: solver.time,
|
|
30
|
+
converged: result.converged ?? true,
|
|
31
|
+
iterations: result.iterations ?? 0,
|
|
32
|
+
steps: result.steps,
|
|
33
|
+
substeps: result.substeps,
|
|
34
|
+
history: result.history,
|
|
35
|
+
};
|
|
36
|
+
if (result.done !== undefined) {
|
|
37
|
+
sol.done = result.done;
|
|
38
|
+
sol.rejected = result.rejected;
|
|
39
|
+
sol.trace = result.trace;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < n; i++) {
|
|
43
|
+
const { name, z } = species[i];
|
|
44
|
+
const cc = new Float64Array(nNodes), mu = new Float64Array(nNodes), mus = new Float64Array(nNodes);
|
|
45
|
+
const V = new Float64Array(nNodes), Vs = new Float64Array(nNodes);
|
|
46
|
+
for (let g = 0; g < nNodes; g++) {
|
|
47
|
+
const k = g * n + i;
|
|
48
|
+
if (!solver.present[k]) {
|
|
49
|
+
cc[g] = mu[g] = mus[g] = V[g] = Vs[g] = NaN;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const o = solver.blockOfNode[g] * M + 1 + i;
|
|
53
|
+
mu[g] = RT * (u[o] + solver.uLo[o]);
|
|
54
|
+
if (solver.nodeConductor[g] >= 0) {
|
|
55
|
+
// A metal has a Fermi level but no concentration or standard level of its own.
|
|
56
|
+
cc[g] = mus[g] = Vs[g] = NaN;
|
|
57
|
+
V[g] = mu[g] / (z * F);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
cc[g] = c[k];
|
|
61
|
+
mus[g] = RT * solver.mu0hat[k] + z * F * phi[g];
|
|
62
|
+
V[g] = z === 0 ? NaN : mu[g] / (z * F);
|
|
63
|
+
Vs[g] = z === 0 ? NaN : mus[g] / (z * F);
|
|
64
|
+
}
|
|
65
|
+
sol.c[name] = cc;
|
|
66
|
+
sol.mu[name] = mu;
|
|
67
|
+
sol.muStd[name] = mus;
|
|
68
|
+
sol.V[name] = V;
|
|
69
|
+
sol.Vstd[name] = Vs;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Contacts: fluxes and displacement at the final state (re-evaluated from the balance rows of
|
|
73
|
+
// the boxes they're read from).
|
|
74
|
+
solver.computeConcentrations();
|
|
75
|
+
solver._assembleBookkeeping(solver.lastDt);
|
|
76
|
+
sol.contacts = {};
|
|
77
|
+
sol.gates = {};
|
|
78
|
+
for (const side of ['left', 'right']) {
|
|
79
|
+
const ct = contacts[side];
|
|
80
|
+
const flux = {};
|
|
81
|
+
let conduction = 0;
|
|
82
|
+
for (let i = 0; i < n; i++) {
|
|
83
|
+
flux[species[i].name] = solver.contactFlux[side][i];
|
|
84
|
+
conduction += F * species[i].z * solver.contactFlux[side][i];
|
|
85
|
+
}
|
|
86
|
+
const D = solver.contactD[side];
|
|
87
|
+
const dt = solver.lastDt;
|
|
88
|
+
const displacement = Number.isFinite(dt) ? (D - solver.contactDOld[side]) / dt : 0;
|
|
89
|
+
// Current toward +x through this contact; in steady state both contacts agree.
|
|
90
|
+
const V = solver.termV[side === 'left' ? 0 : 1];
|
|
91
|
+
sol.contacts[side] = { V, flux, D, current: conduction + displacement };
|
|
92
|
+
if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
|
|
93
|
+
// Charge per area on the gate (or metal) plate: +D at the left, −D at the right.
|
|
94
|
+
sol.gates[side] = { V, D, charge: side === 'left' ? D : -D };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
sol.current = sol.contacts.right.current;
|
|
98
|
+
// Internal ports: what each brings into the device (in steady state, the contact currents
|
|
99
|
+
// differ by the ports' total).
|
|
100
|
+
sol.ports = model.ports.map((port, k) => {
|
|
101
|
+
const flux = {};
|
|
102
|
+
let current = 0;
|
|
103
|
+
for (let i = 0; i < n; i++) {
|
|
104
|
+
flux[species[i].name] = solver.portFlux[k][i];
|
|
105
|
+
current += F * species[i].z * solver.portFlux[k][i];
|
|
106
|
+
}
|
|
107
|
+
return { name: port.name, V: solver.termV[2 + k], flux, current };
|
|
108
|
+
});
|
|
109
|
+
sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
|
|
110
|
+
// Every terminal (the contacts, then the ports by name): its voltage and its current into the
|
|
111
|
+
// device, conduction plus displacement. They sum to zero in steady state.
|
|
112
|
+
sol.terminals = {};
|
|
113
|
+
model.terminals.forEach((t, k) => {
|
|
114
|
+
const current = t.kind === 'port' ? sol.ports[t.index].current : t.side === 'left' ? sol.contacts.left.current : -sol.contacts.right.current;
|
|
115
|
+
sol.terminals[t.name] = { V: solver.termV[k], current };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Interfaces: dipole and the fluxes carried by each flux node.
|
|
119
|
+
sol.interfaces = interfaces.map((itf, f) => {
|
|
120
|
+
const b = solver.blockOfFace[f];
|
|
121
|
+
const N = {};
|
|
122
|
+
for (let i = 0; i < n; i++) N[species[i].name] = u[b * M + 1 + i];
|
|
123
|
+
const rates = itf.reactions.map((_, k) => u[b * M + 1 + n + k]); // mol/(m²·s), forward
|
|
124
|
+
return { dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Total charge per area in the device (space charge plus sheet charges).
|
|
128
|
+
let q = 0;
|
|
129
|
+
for (let g = 0; g < nNodes; g++) {
|
|
130
|
+
let rho = solver.rhoFixed[g];
|
|
131
|
+
for (let i = 0; i < n; i++) rho += F * species[i].z * c[g * n + i];
|
|
132
|
+
q += grid.vol[g] * rho;
|
|
133
|
+
}
|
|
134
|
+
for (const itf of interfaces) q += itf.sheetCharge;
|
|
135
|
+
sol.charge = q;
|
|
136
|
+
|
|
137
|
+
// Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
|
|
138
|
+
// gate contacts), check the local Debye length against the adjacent cell.
|
|
139
|
+
sol.warnings = [...(model.warnings ?? [])];
|
|
140
|
+
const cond = solver.conditioning;
|
|
141
|
+
if (result.converged === false && cond && cond.digits > 12) {
|
|
142
|
+
const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
|
|
143
|
+
sol.warnings.push(
|
|
144
|
+
`the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): part of the device is held ` +
|
|
145
|
+
'only weakly, e.g. a floating region coupled to the rest through tiny conductances or rates, or a stiff ' +
|
|
146
|
+
'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).',
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const debye = (g) => {
|
|
150
|
+
const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
|
|
151
|
+
const s2 = solver.screening(g); // zᵀKz: Σ z² c for ideal statistics
|
|
152
|
+
return mat.epsr > 0 && s2 > 0 ? Math.sqrt((mat.epsr * EPS0 * RT) / (F * F * s2)) : NaN;
|
|
153
|
+
};
|
|
154
|
+
const check = (g, h, where) => {
|
|
155
|
+
const lam = debye(g);
|
|
156
|
+
if (lam < h) {
|
|
157
|
+
sol.warnings.push(
|
|
158
|
+
`${where}: double layer unresolved (cell ${h.toExponential(2)} m vs Debye length ${lam.toExponential(2)} m); ` +
|
|
159
|
+
"its charge will depend on the mesh. Refine the grid there, or use phi: 'neutral' for a macroscopic model.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
interfaces.forEach((itf, f) => {
|
|
164
|
+
if (itf.phi.type === 'neutral') return;
|
|
165
|
+
const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
|
|
166
|
+
check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
|
|
167
|
+
check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
|
|
168
|
+
});
|
|
169
|
+
const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
|
|
170
|
+
if (resolves(contacts.left)) check(0, grid.segLength[0], 'contacts.left');
|
|
171
|
+
if (resolves(contacts.right)) check(nNodes - 1, grid.segLength[nNodes - 2], 'contacts.right');
|
|
172
|
+
|
|
173
|
+
// Steep profiles carrying current: Scharfetter–Gummel takes the field as uniform across each
|
|
174
|
+
// cell, which fails where it isn't, as at an electrode where a species is nearly depleted (there
|
|
175
|
+
// φ̂ ≈ ln c, and on a coarse cell the flux comes out too large: by (1+r)ln(1/r)/(2(1−r)) for
|
|
176
|
+
// a concentration ratio r across it). Flag a region's end cell whose field is well above its
|
|
177
|
+
// neighbour's while its concentration changes steeply, for species carrying the current. (A
|
|
178
|
+
// pn depletion region's field changes smoothly from cell to cell, which SG handles.)
|
|
179
|
+
if (Number.isFinite(sol.current)) {
|
|
180
|
+
const share = species.map((sp, i) =>
|
|
181
|
+
Math.max(Math.abs(sp.z * (sol.contacts.left.flux[sp.name] ?? 0)), Math.abs(sp.z * (sol.contacts.right.flux[sp.name] ?? 0))),
|
|
182
|
+
);
|
|
183
|
+
const total = share.reduce((a, b) => a + b, 0);
|
|
184
|
+
species.forEach((sp, i) => {
|
|
185
|
+
if (sp.z === 0 || !(share[i] > 0.1 * total)) return;
|
|
186
|
+
const cc = sol.c[sp.name];
|
|
187
|
+
let lo = Infinity, hi = -Infinity;
|
|
188
|
+
for (const v of cc) if (Number.isFinite(v)) [lo, hi] = [Math.min(lo, v), Math.max(hi, v)];
|
|
189
|
+
const span = hi - lo;
|
|
190
|
+
if (!(span > 0)) return;
|
|
191
|
+
// At each region end, the two outermost cells, each with its inner neighbour.
|
|
192
|
+
const ends = [];
|
|
193
|
+
model.regions.forEach((reg, r) => {
|
|
194
|
+
const a = grid.regionStart[r], b = grid.regionEnd[r];
|
|
195
|
+
if (model.materials[reg.material].conductor || b - a < 3) return;
|
|
196
|
+
ends.push([[a, a + 1], [a + 1, a + 2]], [[b - 1, b - 2], [b - 2, b - 3]]);
|
|
197
|
+
});
|
|
198
|
+
const field = (j) => (sol.phi[j + 1] - sol.phi[j]) / grid.segLength[j];
|
|
199
|
+
let worst = 0, at = -1, hAt = 0;
|
|
200
|
+
for (const cells of ends) {
|
|
201
|
+
// The bulk fixes the flux, so a cell whose flux comes out too large by e takes too small
|
|
202
|
+
// a share of the concentration drop; the missing drop shifts the whole profile, and the
|
|
203
|
+
// current with it, by about e times the cell's share of the drop.
|
|
204
|
+
let err = 0;
|
|
205
|
+
for (const [s, k] of cells) {
|
|
206
|
+
const ratio = Math.min(cc[s], cc[s + 1]) / Math.max(cc[s], cc[s + 1]);
|
|
207
|
+
if (!(ratio < 0.5) || !(Math.abs(field(s)) > 2 * Math.abs(field(k))) || Math.abs(sol.phi[s + 1] - sol.phi[s]) < VT) continue;
|
|
208
|
+
const e = ((1 + ratio) * Math.log(1 / ratio)) / (2 * (1 - ratio)) - 1;
|
|
209
|
+
err += (e * Math.abs(cc[s + 1] - cc[s])) / span;
|
|
210
|
+
}
|
|
211
|
+
if (err > worst) [worst, at, hAt] = [err, cells[0][0], grid.segLength[cells[0][0]]];
|
|
212
|
+
}
|
|
213
|
+
if (worst > 0.003) {
|
|
214
|
+
sol.warnings.push(
|
|
215
|
+
`${sp.name}: a steep profile near x = ${grid.x[at].toExponential(3)} m on a ${hAt.toExponential(2)} m cell, where the field ` +
|
|
216
|
+
`isn't uniform across the cell; the current may come out too large (an estimate, likely low: ${(100 * worst).toFixed(worst < 0.1 ? 1 : 0)}%). ` +
|
|
217
|
+
'Refine the grid toward that end (a smaller hmin).',
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Conservation bookkeeping for each species stretch.
|
|
224
|
+
// Amount now vs the reference amount plus what came in through the contacts. The drift is
|
|
225
|
+
// relative to the larger of the two; NaN where a reaction also makes or consumes it.
|
|
226
|
+
sol.conservation = solver.stretches.map((st, k) => {
|
|
227
|
+
const amount = solver.amount(st);
|
|
228
|
+
const reference = solver.referenceAmounts[k];
|
|
229
|
+
const intake = solver.boundaryIntake[k];
|
|
230
|
+
const expected = reference + intake;
|
|
231
|
+
const scale = Math.max(Math.abs(expected), Math.abs(amount), Math.abs(intake), 1e-300);
|
|
232
|
+
return {
|
|
233
|
+
species: species[st.species].name,
|
|
234
|
+
regions: st.regions.slice(),
|
|
235
|
+
spectator: st.spectator,
|
|
236
|
+
connected: st.connected,
|
|
237
|
+
reactive: st.reactive,
|
|
238
|
+
amount,
|
|
239
|
+
reference,
|
|
240
|
+
intake,
|
|
241
|
+
drift: st.reactive ? NaN : (amount - expected) / scale,
|
|
242
|
+
};
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return sol;
|
|
246
|
+
}
|