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.
Files changed (47) hide show
  1. package/LICENSE +14 -0
  2. package/README.md +285 -0
  3. package/llms.txt +299 -0
  4. package/package.json +54 -0
  5. package/src/bernoulli.js +29 -0
  6. package/src/blockTridiagonal.js +492 -0
  7. package/src/constants.js +36 -0
  8. package/src/data.js +127 -0
  9. package/src/describe.js +190 -0
  10. package/src/device.js +954 -0
  11. package/src/equation.js +188 -0
  12. package/src/errors.js +8 -0
  13. package/src/fermi.js +106 -0
  14. package/src/grid.js +111 -0
  15. package/src/index.js +207 -0
  16. package/src/kit.js +76 -0
  17. package/src/live.js +217 -0
  18. package/src/merge.js +11 -0
  19. package/src/plot.js +267 -0
  20. package/src/solution.js +246 -0
  21. package/src/solver.js +2884 -0
  22. package/src/stack.js +172 -0
  23. package/src/statistics.js +593 -0
  24. package/src/traces.js +125 -0
  25. package/src/types.js +221 -0
  26. package/src/worker.js +24 -0
  27. package/types/bernoulli.d.ts +4 -0
  28. package/types/blockTridiagonal.d.ts +92 -0
  29. package/types/constants.d.ts +33 -0
  30. package/types/data.d.ts +73 -0
  31. package/types/describe.d.ts +15 -0
  32. package/types/device.d.ts +20 -0
  33. package/types/equation.d.ts +102 -0
  34. package/types/errors.d.ts +3 -0
  35. package/types/fermi.d.ts +3 -0
  36. package/types/grid.d.ts +28 -0
  37. package/types/index.d.ts +103 -0
  38. package/types/kit.d.ts +61 -0
  39. package/types/live.d.ts +101 -0
  40. package/types/merge.d.ts +1 -0
  41. package/types/plot.d.ts +60 -0
  42. package/types/solution.d.ts +18 -0
  43. package/types/solver.d.ts +342 -0
  44. package/types/stack.d.ts +59 -0
  45. package/types/statistics.d.ts +10 -0
  46. package/types/traces.d.ts +118 -0
  47. package/types/types.d.ts +574 -0
@@ -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
+ }