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.
@@ -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.10.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/plot.js';
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.10.0/src/index.js';
422
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/kit.js';
423
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/plot.js';
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.10.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",
@@ -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) return 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 add = (i, r, what, rate) => {
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
- for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total);
116
- for (const { i, nu } of rx.products) add(i, r, what, nu * total);
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
- for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
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 could carry) isn't reported.
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 moving = ledgers.filter((l) => size(l) > 1e-9 * natural[l.i]);
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
- function naturalScales(device, sol) {
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. A change to the terminals'
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 contact given a new kind of drive drops the old one: { I: 0 } replaces a held V.
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
- if ('I' in c && !('V' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, V: undefined } } };
79
- else if ('V' in c && !('I' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, I: undefined } } };
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 && cond && cond.digits > 12) {
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 ` +