driftlet 0.14.0 → 0.16.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/llms.txt CHANGED
@@ -28,9 +28,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
28
28
  From a CDN, pin the version you tested against:
29
29
 
30
30
  ```js nocheck
31
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/index.js';
32
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/kit.js';
33
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/plot.js';
31
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/index.js';
32
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/kit.js';
33
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/plot.js';
34
34
  ```
35
35
 
36
36
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
@@ -61,8 +61,9 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
61
61
  - **Check before trusting.** `sol.converged` must be true; read `sol.warnings`, where a failed
62
62
  solve always says why. `describe(def)` (kit) summarises a definition and flags likely unit
63
63
  slips. Once a device works, log `check(dev, sol).text` (kit) once: each species' ledger,
64
- conservation in transients, and a steady state on a grid twice as fine (a FAIL means refine;
65
- a transient you rerun on a finer grid yourself).
64
+ conservation in transients, a steady state on a grid twice as fine (a FAIL means refine;
65
+ a transient you rerun on a finer grid yourself) and with its species listed in reverse (a
66
+ FAIL there means a solve converged somewhere that isn't steady: compare a long advance()).
66
67
 
67
68
  ## How a device is written
68
69
 
@@ -73,14 +74,15 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
73
74
  - **The library** is pieces with `species` and `materials`: `semiconductor('Si')` (Si, Ge, GaAs, at
74
75
  300 K), `aqueous(names)` (H⁺ Li⁺ Na⁺ K⁺ Ag⁺ Cu²⁺ Zn²⁺ Fe²⁺ Fe³⁺ OH⁻ Cl⁻ NO₃⁻ SO₄²⁻ in water, at 25
75
76
  °C; `{ epsr: 0 }` makes it strictly neutral, with no double layers), `metal('Ag')` (Ag Cu Au Al Zn
76
- Li Pt). A semiconductor's textbook numbers are in `SEMICONDUCTORS.Si` (`Eg` and `chi` in eV, `Nc`
77
- and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the material, e⁻'s and h⁺'s `cRef` are
78
- N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0), so n_i = √(N_c N_v) e^(−E_g/2RT),
79
- 6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists (`z: 0` for a neutral one):
80
- `lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`, `lib.materials.water.species['A-'] = { D,
81
- mu0 }`. Only μ° differences within a reaction matter: for a redox couple μ°_red − μ°_ox = −F·E°
82
- (against SHE, the library's μ°_H⁺ being 0). An insulator is a material with no species: `{
83
- species: [], materials: { SiO2: { epsr: 3.9, species: {} } } }`.
77
+ Li Pt), all from `driftlet/kit`. A semiconductor's textbook numbers are in `SEMICONDUCTORS.Si`
78
+ (`Eg` and `chi` in eV, `Nc` and `Nv` in cm⁻³, `mun` and `mup` in cm²/(V·s), `epsr`); in the
79
+ material, e⁻'s and h⁺'s `cRef` are N_c and N_v in mol/m³ and h⁺'s `mu0` is the gap (e⁻'s is 0),
80
+ so n_i = √(N_c N_v) e^(−E_g/2RT), 6.7e9 cm⁻³ for Si. A species it lacks is pushed into both lists
81
+ (`z: 0` for a neutral one): `lib.species.push({ name: 'A-', z: -1, cRef: 1000 })`,
82
+ `lib.materials.water.species['A-'] = { D, mu0 }`. Only μ° differences within a reaction matter:
83
+ for a redox couple μ°_red − μ°_ox = −F·E° (against SHE, the library's μ°_H⁺ being 0). An
84
+ insulator is a material with no species: `{ species: [], materials: { SiO2: { epsr: 3.9, species:
85
+ {} } } }`.
84
86
  - **Faces.** Between two different non-metals, give an alignment, `{ dipole }` or `{ step: {
85
87
  species: 'e-', value } }` (J/mol), or `phi: 'neutral'`. `vacuumDipole(def, { material, anchor:
86
88
  'e-', offset: χ }, …)` estimates a dipole from electron affinities (Anderson's rule); it reads
@@ -95,7 +97,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
95
97
  (`capacitance`, `gates`). A BJT's base contact is a port on a narrow window of the base,
96
98
  `{ region: 'base', from, to, V, terminal: 'h+', species: { 'h+': 'equilibrium' } }`.
97
99
  - **Drives.** A terminal is held at a voltage `V`, driven by a current `I` (open circuit is
98
- `I: 0`), or behind a resistance `{ V, R }`. Either can be a waveform: a table `{ t, values }`
100
+ `I: 0`), or behind a resistance `{ V, R }`; in `build()`, the same object goes where the
101
+ voltage would (`ohmic({ I: 0 }, ['e-'])`, `ohmic({ V: 1, R: 1e3 }, ['e-'])`). Either can be a waveform: a table `{ t, values }`
99
102
  (piecewise linear; a step is two points at one time), or the kit's `pulse({ start, width,
100
103
  amplitude, rise, base })`, `square({ high, low, period, duty })`, `ramp({ from, to, duration,
101
104
  start })` or `triangle({ from, to, period })` (out and back each period from t = 0: a cyclic
@@ -111,8 +114,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at
111
114
  { tauN, tauP } }` in place of `kf` (s), or at a face `{ equation: 'e-(left) + h+(right) = 0',
112
115
  srh: { vn, vp } }` (m/s); with τ_N ≪ τ_p it gives n-type silicon's holes a lifetime τ_p at any
113
116
  injection. Light is a reaction from a reservoir (`photon = e- + h+`, uniform), or absorbed as
114
- it goes in,
115
- `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`.
117
+ it goes in, `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })`. To switch
118
+ it during a run, `set()` the device's `bulkReactions` (the state and the step size carry over).
116
119
  - **Electrode kinetics.** A face reaction's `k0` (mol/(m²·s)) is its rate with every participant
117
120
  at cRef: i₀ = nF·k0 there, and k0 = k°·cRef for a textbook k° in m/s (1 cm/s at 1 M is
118
121
  `k0: 10`).
@@ -145,6 +148,14 @@ const tK = D['K+'] / (D['K+'] + D['Cl-']), VT = (GAS_CONSTANT * 298.15) / FARADA
145
148
  console.log(`EMF ${(sol.terminals.right.V * 1e3).toFixed(3)} mV, 2t₊V_T ln 10 = ${(2 * tK * VT * Math.log(10) * 1e3).toFixed(3)} mV`); // 58.048, 58.047
146
149
  ```
147
150
 
151
+ An ion-exchange membrane is a layer whose material has its ions slower, with a `fixedCharge` (−X·F
152
+ for X mol/m³ of fixed anions), between layers of water. A bath against the membrane itself names
153
+ its own material, `bath(c, 'Cl-', 0, { material: 'water' })`, and meets it through a Donnan step.
154
+ An exchange or partition constant between the membrane and the water is a difference of μ°:
155
+ give the membrane's ions μ° shifted from the water's by s_i, and K = e^(−(s_Na − s_H)/RT) for
156
+ Na⁺ against H⁺ (a large K draws in salt too, unless the co-ion's s is raised). The membrane
157
+ demo's page script is a worked example.
158
+
148
159
  ## Template: a semiconductor junction
149
160
 
150
161
  ```js
@@ -193,9 +204,9 @@ a CDN needs a long virtual-time budget, 30 s.)
193
204
  <span id="readout"></span>
194
205
  <div id="figure"></div>
195
206
  <script type="module">
196
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/index.js';
197
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/kit.js';
198
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.14.0/src/plot.js';
207
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/index.js';
208
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/kit.js';
209
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.16.4/src/plot.js';
199
210
 
200
211
  const def = build({
201
212
  T: 300,
@@ -220,17 +231,15 @@ see "Reading level diagrams" below.
220
231
 
221
232
  For an explainer, `particles(sol, { weight, cap })` (kit) draws a solution as dots, each `weight`
222
233
  mol/m² of a species, hopping at rates taken from the solution: on average their density is its
223
- concentration and their net crossing its flux. A friendly first figure for teaching, as long as
224
- the page says it samples the continuum solution and how much a dot is. Where a dot would be lost
225
- among millions (majority carriers, a bath), a cell over `cap` dots is a sea: shade it by
226
- `expected(name)`. Each frame: `swarm.step(dt)` in the device's time (about h²/D per hop, h a
227
- cell), then draw `swarm.dots` (`{ species, x }`) and `swarm.events` (`'unmade'` is a
228
- recombination; `'cross'` an ion through a membrane between two seas); on a new steady solution,
229
- `swarm.update(sol, { resample: true })`. A membrane's permeability (1e-8 m/s) is tiny beside
230
- D/h in the water (0.02 m/s for 100 nm cells), so an ion beside it hops ~1e6 times per crossing:
231
- a membrane page shows seas and crossing events, not walking ions.
232
- The pn demo's page script draws a diode's carriers on its band diagram this way, and the axon
233
- demo's the ions crossing its membrane (`cap: 0`, every cell a sea, so only the crossings).
234
+ concentration and their net crossing its flux. Say on the page that it samples the continuum
235
+ solution, and how much a dot is. Where a dot would be lost among millions (majority carriers, a
236
+ bath), a cell over `cap` dots is a sea: shade it by `expected(name)`. Each frame,
237
+ `swarm.step(dt)` in the device's time (about h²/D per hop, h a cell), then draw `swarm.dots`
238
+ (`{ species, x }`) and `swarm.events` (`'unmade'` is a recombination, `'cross'` an ion through a
239
+ membrane between two seas); on a new steady solution, `swarm.update(sol, { resample: true })`.
240
+ An ion beside a cell membrane hops ~1e6 times per crossing, so a membrane page shows seas and
241
+ crossing events, not walking ions. The demos' page scripts do this for a diode's carriers (pn),
242
+ ions crossing a nerve membrane (axon, `cap: 0`) and Donnan exclusion (membrane).
234
243
 
235
244
  ## Template: a solar cell
236
245
 
@@ -263,6 +272,15 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
263
272
  console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
264
273
  ```
265
274
 
275
+ In an organic cell, light makes excitons: a neutral species `X` in the donor, made by
276
+ `photogeneration({ ..., makes: 'X' })` and decaying by `{ equation: 'X = 0', kf: { donor: 1 / tau } }`.
277
+ They split at the donor/acceptor face by a reaction there, `X = h+(left) + e-(right)` with e⁻ and
278
+ h⁺ blocked at the face (the reaction moves them), and its reverse is charge-transfer
279
+ recombination; the face's `step` sets E_CT. With `alpha: 0` the splitting is a velocity
280
+ k0/cRef, a Robin condition on the exciton profile; with α > 0 it's k0/cRef · e^(α(E_X − E_CT)/RT).
281
+ The organic demo's page script is a worked example (J_sc against exciton diffusion theory, V_oc
282
+ against detailed balance).
283
+
266
284
  For a perovskite cell, prefer the curated `perovskiteCell({ V, light, bulkSRH })`: IonMonger's
267
285
  default cell (mobile iodide vacancies), with its J–V scans at seven rates in `PEROVSKITE_SCANS`
268
286
  and `hysteresis(trace, turn)` to read the same figures from yours.
@@ -308,11 +326,14 @@ for (const Vg of [-2, -0.95, 0, 0.4, 2]) {
308
326
  console.log(`V_G ${Vg} V: C/C_ox ${(C / Cox).toFixed(3)}`); // 0.949, 0.701 (flat band), 0.267, 0.375, 0.973
309
327
  }
310
328
  // At flat band the silicon is a capacitor of ε/L_D, its Debye length's.
311
- const LD = Math.sqrt((11.7 * EPS0 * GAS_CONSTANT * 300) / (FARADAY * FARADAY * NA));
312
- console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.7 * EPS0))).toFixed(3)}`); // 0.699
329
+ const LD = Math.sqrt((11.9 * EPS0 * GAS_CONSTANT * 300) / (FARADAY * FARADAY * NA)); // (the kit's Si: ε 11.9)
330
+ console.log(`flat band, theory: ${(1 / (1 + (Cox * LD) / (11.9 * EPS0))).toFixed(3)}`); // 0.701
313
331
  ```
314
332
 
315
- `dev.impedance([f], { terminal: 'left' })` gives the same C at low frequency. Draw it with
333
+ `dev.impedance([f], { terminal: 'left' })` (C = Im(1/Z)/ω, from `{ f, Z: { re, im } }`) gives
334
+ the same C at any frequency with the port; without it, only below the minority supply's knee
335
+ (generation, or diffusion from the back contact: 0.01 Hz and below), and the high-frequency C–V
336
+ above. Draw it with
316
337
  `bandDiagram(sol, { energy: true, phi: ['SiO2'] })`: across an insulator φ is the line to draw.
317
338
 
318
339
  ## Template: a Schottky diode
@@ -419,10 +440,12 @@ console.log(level(sol, silver)[mid] - level(sol, SHE, { standard: true })[mid]);
419
440
 
420
441
  A working electrode, a stagnant layer, and the stirred bulk as a bath whose terminal is its
421
442
  reference species (Cl⁻: an Ag/AgCl reference). An electrode's potential against SHE is its
422
- electrons' V minus φ in the bulk (with the library's μ°). The working electrode's current is
423
- positive when oxidising. The layer's resistance is in series (uncompensated IR: keep it thin, or
424
- the supporting salt strong); a 100:1 excess of salt still leaves the limiting current about 1%
425
- off pure diffusion.
443
+ electrons' V minus φ in the bulk (with the library's μ°), and a Cl⁻ terminal sits ~1.36 V above
444
+ that, so an electrode held at 0 V beside it is strongly anodic: `describe(def)` says where SHE
445
+ sits, and an offset of that much (`bath(c, 'Cl-', 0, { offset })`) makes the terminal read SHE.
446
+ The working electrode's current is positive when oxidising. The layer's resistance is in series
447
+ (uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of salt still
448
+ leaves the limiting current about 1% off pure diffusion.
426
449
 
427
450
  ```js
428
451
  import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
@@ -665,7 +688,9 @@ capacitance and gated channels to an outside given as a `bath` (the port's V is
665
688
  outside's φ). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
666
689
  per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
667
690
  `ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
668
- its own port (an internode's just a small capacitance).
691
+ its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
692
+ the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
693
+ (scale h with √a). The stimulus is a current per volume, so a thinner axon needs it as 1/a.
669
694
 
670
695
  ```js
671
696
  import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
@@ -710,8 +735,15 @@ species }` (its flux) or `{ interface: f, gate }`.
710
735
 
711
736
  - **Events.** To stop at one (a cut-off voltage, a diode's storage time), advance in short calls
712
737
  and test after each: a driven current held past what the device can pass fails to converge.
713
- - **Storage.** A device that only stores what comes in (a battery host filling at a current) has
714
- no steady state: `advance()` it from its `c0`, don't `solve()` it (docs/statistics.md).
738
+ - **The first instants.** The first step is sized by error control (from 1e-4 of the interval),
739
+ so a fast start (an initial plateau, a charging spike) can fall inside it: give `dt0` (and
740
+ `dtMax`) to see it. A voltage step's first point carries the displacement that charges the bulk
741
+ at once (a spike of ~1e4 A/m² for ~1e-10 s across a cell's Stern layers): leave it out of
742
+ integrals of I²R.
743
+ - **Storage.** A device that only stores what comes in (a battery host filling at a current, a
744
+ gate charging) has no steady state: `advance()` it from its `c0`, don't `solve()` it
745
+ (docs/statistics.md). At no current (`I: 0`), `solve()` keeps the charge it holds: a closed
746
+ battery sits at the OCV of its state of charge, a floating gate keeps its charge.
715
747
  - **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
716
748
  { terminal })` the small-signal Z(f) about the steady state, in Ω·m².
717
749
 
@@ -803,7 +835,7 @@ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualizat
803
835
  everything floating (hold one terminal at a `V`), a species that reaches no contact without a
804
836
  `c0`, an unbalanced `c0` in a closed neutral region (add the counter-ion's share), `'2e-'` for
805
837
  `'2 e-'`, a current drive of the wrong sign. `interfaces[f]` is the stack's f-th face.
806
- - **A gate as a contact on an oxide.** It fails as `'e-' is blocked at this contact`: make the
838
+ - **A gate as a contact on an oxide.** It fails as `'e-' is absent from the end material 'SiO2'`: make the
807
839
  gate a metal layer with a capacitive face, as in the MOS template.
808
840
  - **`ohmic()` on a metal.** Its default holds e⁻ and h⁺; on a metal write `ohmic(V, ['e-'])`.
809
841
  - **Ions on both sides of a reacting face.** Label them, `'Li+(left) = Li+(right)'`, and block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.14.0",
3
+ "version": "0.16.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": {
@@ -110,16 +110,29 @@ export class BlockTridiagonal {
110
110
  if (i === 0 || sizes[i - 1] === 0) {
111
111
  for (let k = 0; k < m * m; k++) lu[o + k] = B[o + k];
112
112
  } else {
113
+ // Row by row, skipping A's zeros (a gate or a local unknown has none to its neighbours):
114
+ // the same sums, subtracted in the same order.
113
115
  const mp = sizes[i - 1], oa = offA[i], op = offC[i - 1]; // C'_{i−1}: mp × m
114
116
  for (let r = 0; r < m; r++) {
115
- for (let c = 0; c < m; c++) {
116
- let s = B[o + r * m + c];
117
- for (let k = 0; k < mp; k++) s -= A[oa + r * mp + k] * cp[op + k * m + c];
118
- lu[o + r * m + c] = s;
117
+ const or = o + r * m;
118
+ for (let c = 0; c < m; c++) lu[or + c] = B[or + c];
119
+ for (let k = 0; k < mp; k++) {
120
+ const a = A[oa + r * mp + k];
121
+ if (a === 0) continue;
122
+ const ok = op + k * m;
123
+ for (let c = 0; c < m; c++) lu[or + c] -= a * cp[ok + c];
119
124
  }
120
125
  }
121
126
  }
122
- const ratio = luFactor(lu, o, piv, offX[i], m, this);
127
+ let ratio = luFactor(lu, o, piv, offX[i], m, this);
128
+ // A pivot far below the entries it's formed from may be round-off alone (the level of a
129
+ // region that only the blocks after it hold): its value, 7e-32 or 3e-16 or 0 as the
130
+ // unknowns happen to be listed, then scales all that follows (by 1e31, and the next block
131
+ // lost its own pivot outright). Redone with each entry's magnitudes carried (see
132
+ // cancellation), a pivot 1e-18 of them, all its digits lost, is perturbed like one that
133
+ // cancelled to exactly nothing. (At 1e-15 it can still hold a digit Newton needs: a port's
134
+ // steady solve wandered instead of converging.)
135
+ if (this.staticPivots && ratio < 1e-12) ratio = this._noisyPivots(i);
123
136
  if (!(ratio > 0)) {
124
137
  this.factored = false;
125
138
  this.singularBlock = i;
@@ -130,8 +143,9 @@ export class BlockTridiagonal {
130
143
  if (i < n - 1) {
131
144
  const mn = sizes[i + 1], oc = offC[i];
132
145
  for (let c = 0; c < mn; c++) {
133
- for (let r = 0; r < m; r++) tmp[r] = C[oc + r * mn + c];
134
- luSolve(lu, o, piv, offX[i], m, tmp);
146
+ let any = false;
147
+ for (let r = 0; r < m; r++) if ((tmp[r] = C[oc + r * mn + c]) !== 0) any = true;
148
+ if (any) luSolve(lu, o, piv, offX[i], m, tmp); // (a zero column stays zero)
135
149
  for (let r = 0; r < m; r++) cp[oc + r * mn + c] = tmp[r];
136
150
  }
137
151
  }
@@ -140,6 +154,70 @@ export class BlockTridiagonal {
140
154
  this.factored = true;
141
155
  }
142
156
 
157
+ // Block i again, its W and LU with the magnitudes each entry is formed from (as cancellation()
158
+ // does), perturbing pivots 1e-18 of theirs. Returns the block's pivot ratio.
159
+ _noisyPivots(i) {
160
+ const { A, B, sizes, offA, offB, offC, offX } = this, cp = this._cp, lu = this._lu, piv = this._piv;
161
+ const m = sizes[i], mp = i > 0 ? sizes[i - 1] : 0, oa = offA[i], o = offB[i], oc = i > 0 ? offC[i - 1] : 0;
162
+ const S = (this._mag ??= new Float64Array(this._tmp.length * this._tmp.length));
163
+ for (let r = 0; r < m; r++) {
164
+ for (let c = 0; c < m; c++) {
165
+ let v = B[o + r * m + c], sum = Math.abs(v);
166
+ for (let k = 0; k < mp; k++) {
167
+ const a = A[oa + r * mp + k];
168
+ if (a === 0) continue;
169
+ const t = a * cp[oc + k * m + c];
170
+ v -= t;
171
+ sum += Math.abs(t);
172
+ }
173
+ lu[o + r * m + c] = v;
174
+ S[r * m + c] = sum;
175
+ }
176
+ }
177
+ let maxEntry = 0;
178
+ for (let k = 0; k < m * m; k++) maxEntry = Math.max(maxEntry, Math.abs(lu[o + k]));
179
+ if (maxEntry === 0) return 0;
180
+ let minPivot = Infinity;
181
+ for (let k = 0; k < m; k++) {
182
+ let best = k, bestVal = Math.abs(lu[o + k * m + k]);
183
+ for (let r = k + 1; r < m; r++) {
184
+ const v = Math.abs(lu[o + r * m + k]);
185
+ if (v > bestVal) { best = r; bestVal = v; }
186
+ }
187
+ piv[offX[i] + k] = best;
188
+ if (best !== k) {
189
+ for (let c = 0; c < m; c++) {
190
+ let t = lu[o + k * m + c];
191
+ lu[o + k * m + c] = lu[o + best * m + c];
192
+ lu[o + best * m + c] = t;
193
+ t = S[k * m + c];
194
+ S[k * m + c] = S[best * m + c];
195
+ S[best * m + c] = t;
196
+ }
197
+ }
198
+ const sum = S[k * m + k];
199
+ if (bestVal <= 1e-18 * sum || bestVal === 0) {
200
+ const size = sum > 0 ? 1e-15 * sum : 1e-15 * maxEntry;
201
+ bestVal = size;
202
+ lu[o + k * m + k] = lu[o + k * m + k] < 0 ? -size : size;
203
+ this.perturbedPivots++;
204
+ }
205
+ if (bestVal < minPivot) minPivot = bestVal;
206
+ const inv = 1 / lu[o + k * m + k];
207
+ for (let r = k + 1; r < m; r++) {
208
+ const f = (lu[o + r * m + k] *= inv);
209
+ if (f !== 0) {
210
+ const af = Math.abs(f);
211
+ for (let c = k + 1; c < m; c++) {
212
+ lu[o + r * m + c] -= f * lu[o + k * m + c];
213
+ S[r * m + c] += af * S[k * m + c];
214
+ }
215
+ }
216
+ }
217
+ }
218
+ return minPivot / maxEntry;
219
+ }
220
+
143
221
  /**
144
222
  * Solve with the current factorisation. `rhs` and `out` are Float64Arrays of length `size`
145
223
  * (node-major); they may be the same array.
package/src/checks.js CHANGED
@@ -29,6 +29,11 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
29
29
  * zero; under light it's J = F(G − R), and it says where every carrier went.
30
30
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
31
31
  * what came in.
32
+ * - **order** (a steady state, with `refine` and `order`, the defaults): the device solved from
33
+ * its definition twice, its species listed as given and in reverse, which must give the same
34
+ * steady state (each terminal's voltage and current, each species' amount). A solve that
35
+ * depends on the listing has converged somewhere that isn't steady without noticing (round 11's
36
+ * fuzzer found it in ~1% of random reacting devices, by exactly this test).
32
37
  * - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
33
38
  * answer depends on the whole run): in a steady state, the device solved again on a grid
34
39
  * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
@@ -37,12 +42,12 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
37
42
  * (about 3/4 of it).
38
43
  * @param {import('./index.js').Device} device
39
44
  * @param {object} [sol] the solution to check (default: the device's present state)
40
- * @param {{ refine?: boolean, tol?: number }} [opts]
45
+ * @param {{ refine?: boolean, tol?: number, order?: boolean }} [opts]
41
46
  * Every check passed when `ok`; an item with `ok: null` (a warning, or a check that couldn't be
42
47
  * made) asks you to look, and doesn't fail it. `text` is a line per check, marked ok, FAIL or ?.
43
48
  * @returns {{ ok: boolean, items: CheckItem[], text: string }}
44
49
  */
45
- export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
50
+ export function check(device, sol, { refine = true, tol = 1e-2, order = true } = {}) {
46
51
  sol ??= device.solution();
47
52
  const { model, solver, def } = device;
48
53
  const steady = sol.steady ?? solver.atSteady;
@@ -66,8 +71,17 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
66
71
  // The rest checks a solution, which an unconverged state isn't.
67
72
  if (sol.converged) {
68
73
  const ledger = steady ? balance(device, sol) : null;
74
+ // A reaction the steady state needs at equilibrium, left far from it (too slow to see: the
75
+ // solution warns of it), isn't steady, however quietly its ledgers sum: "equilibrium" was
76
+ // printed beside that warning.
77
+ const stuck = steady ? solver.stuckReactions() : [];
78
+ if (ledger && stuck.length > 0) {
79
+ ledger.ok = false;
80
+ ledger.summary = `not a steady state: ${stuck.map((s) => `${s.where} is ${sig(s.affinity * solver.VT * 1e3)} mV of affinity from equilibrium`).join(', ')}, too slow for the solve to see (see the warnings); its ledgers say only that nothing moves fast enough to count`;
81
+ }
69
82
  items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
70
83
  if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
84
+ if (steady && refine && order && model.species.length > 1) items.push(orderCheck(device, sol, ledger.details.gross));
71
85
  // A transient's answer depends on its whole history (and on drives set along the way), so
72
86
  // only a run of its own on a finer grid can check it.
73
87
  else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
@@ -110,9 +124,12 @@ function balance(device, sol) {
110
124
  else ledgers[l].terms.push({ what, rate });
111
125
  };
112
126
  const last = regions.length - 1;
127
+ // (A contact that blocks a species is no source of it: what its flux reads there is the end
128
+ // box's own imbalance, which the ledger must show, not balance.)
129
+ const through = (side, i) => model.contacts[side].species[i].type !== 'blocked';
113
130
  species.forEach((sp, i) => {
114
- add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
115
- add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
131
+ if (through('left', i)) add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
132
+ if (through('right', i)) add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
116
133
  model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
117
134
  });
118
135
  model.reactions.forEach((rx, k) => {
@@ -259,8 +276,12 @@ function gridCheck(device, sol, tol, gross) {
259
276
  const diffs = [];
260
277
  // The current, against the largest current any species carries (so at open circuit, or in
261
278
  // equilibrium, round-off isn't read as a change).
262
- const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
263
- if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
279
+ // A current too small for the ledgers to call moving (a diode at low bias: 1e-4 A/m² against
280
+ // majority carriers that could carry 6e8) is compared still, above a floor at 1e-16 of what the
281
+ // species could carry, above what solves resolve (~1e-19 of it in a GaAs junction).
282
+ const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
283
+ const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross, floorI);
284
+ if (gross > 0 || Math.max(Math.abs(sol.current), Math.abs(other.current)) > floorI) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
264
285
  // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
265
286
  const VT = model.RT / FARADAY;
266
287
  model.terminals.forEach((t, k) => {
@@ -307,6 +328,62 @@ function gridCheck(device, sol, tol, gross) {
307
328
  };
308
329
  }
309
330
 
331
+ // The device solved from its definition with its species listed as given and in reverse: the
332
+ // same steady state either way, or one of them isn't steady. (Both from the definition, not this
333
+ // state, whose closed amounts a transient or set() may have moved from c0.)
334
+ function orderCheck(device, sol, gross) {
335
+ const { def, model } = device;
336
+ const solveAt = (d) => {
337
+ const dev = new Device(d);
338
+ if (sol.time > 0) dev.solver.time = sol.time; // waveforms at the same time
339
+ return dev.solve();
340
+ };
341
+ let a, b;
342
+ try {
343
+ a = solveAt(def);
344
+ b = solveAt({ ...def, species: [...def.species].reverse() });
345
+ } catch (e) {
346
+ return { name: 'order', ok: null, summary: `could not solve the device with its species reversed (${e.message})` };
347
+ }
348
+ if (!a.converged || !b.converged) {
349
+ const which = a.converged ? 'listed in reverse' : b.converged ? 'listed as given' : 'listed either way';
350
+ return { name: 'order', ok: null, summary: `the device solved from its definition did not converge with its species ${which}: compare with a long advance() by hand` };
351
+ }
352
+ const VT = model.RT / FARADAY, diffs = [];
353
+ // (Currents only where the ledgers see something move: in equilibrium they're round-off of
354
+ // whatever exchanges fastest, an electrode's couple, and a wrong state shows in the voltages
355
+ // and amounts instead.)
356
+ // (and above 1e-16 of what the species could carry, as the grid check's floor: a drift at
357
+ // 1e-16 A/m², still moving at 1e12 s, isn't worth reporting)
358
+ const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
359
+ for (const [name, t] of Object.entries(a.terminals)) {
360
+ const u = b.terminals[name];
361
+ diffs.push({ what: `${name} voltage`, a: t.V, b: u.V, rel: Math.abs(u.V - t.V) / (1e-6 * Math.max(VT, Math.abs(t.V))), unit: 'V' });
362
+ if (gross > 0 && Math.max(Math.abs(t.current), Math.abs(u.current), gross) > floorI) diffs.push({ what: `${name} current`, a: t.current, b: u.current, rel: Math.abs(u.current - t.current) / (1e-3 * Math.max(Math.abs(t.current), Math.abs(u.current), gross)), unit: unitsOf(model).current });
363
+ }
364
+ const vol = model.grid.vol, amount = (s, name) => s.x.reduce((m, _, g) => m + (Number.isFinite(s.c[name][g]) ? vol[g] * s.c[name][g] : 0), 0);
365
+ const amounts = model.species.map((sp) => [sp.name, amount(a, sp.name), amount(b, sp.name)]);
366
+ const most = Math.max(0, ...amounts.map(([, x, y]) => Math.max(Math.abs(x), Math.abs(y))));
367
+ for (const [name, x, y] of amounts) diffs.push({ what: `${name} amount`, a: x, b: y, rel: Math.abs(y - x) / (1e-6 * Math.max(Math.abs(x), Math.abs(y)) + 1e-12 * most), unit: 'mol/m²' });
368
+ // Agreeing to near round-off (a µV, a millionth of an amount): ok. Within a thousand times that
369
+ // (a mV, a thousandth), enough for any plot, but a weakly held part of one solve not quite
370
+ // steady: look. Beyond: one of them isn't a steady state.
371
+ const worst = diffs.reduce((w, d) => (d.rel > (w?.rel ?? -1) ? d : w), null);
372
+ const ok = !(worst.rel > 1) ? true : worst.rel > 1e3 ? false : null;
373
+ const what = `${worst.what} ${sig(worst.a, 6)} → ${sig(worst.b, 6)} ${worst.unit}`;
374
+ return {
375
+ name: 'order',
376
+ ok,
377
+ summary:
378
+ ok === true
379
+ ? 'the same steady state with the species listed in reverse'
380
+ : ok === null
381
+ ? `listed in reverse, the species give nearly the same steady state (${what}): not to round-off, so some weakly held part is not quite steady in one of them`
382
+ : `listed in reverse, the species give a different steady state: ${what}. One of them is not steady, though its solve converged: compare with a long advance(), and please report the device.`,
383
+ details: { diffs },
384
+ };
385
+ }
386
+
310
387
  // Charge density at a node of region r, C/m³: the species plus the region's fixed charge.
311
388
  function rho(s, g, model, r) {
312
389
  let q = model.regions[r].fixedCharge ?? 0;
package/src/describe.js CHANGED
@@ -49,6 +49,10 @@ export function unitWarnings(def) {
49
49
  if (c > 1e6) warn(path, `${num(c)} mol/m³ is over 1000 M; concentrations are mol/m³ (units.molar, and units.perCm3 for carriers)`);
50
50
  };
51
51
  (def.species ?? []).forEach((sp, i) => sp?.cRef !== undefined && conc(`species[${i}].cRef`, sp.cRef));
52
+ // SRH's n₁ is a concentration too (a value per cm³ typed in is 6e5 times too large)
53
+ (def.bulkReactions ?? []).forEach((rx, k) => {
54
+ for (const [m, law] of Object.entries(isObject(rx?.srh) ? rx.srh : {})) if (isObject(law) && law.n1 !== undefined) conc(`bulkReactions[${k}].srh.${m}.n1`, law.n1);
55
+ });
52
56
  for (const [mname, mat] of Object.entries(def.materials ?? {})) {
53
57
  if (!isObject(mat)) continue;
54
58
  const path = `materials.${mname}`;
@@ -92,13 +96,19 @@ export function unitWarnings(def) {
92
96
  };
93
97
  const kinetics = (path, reactions) => {
94
98
  (reactions ?? []).forEach((rx, k) => {
95
- if (rx?.k0 > 10) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
99
+ // (an exchange current only where electrons take part: an ion transfer's k0 can be large)
100
+ const said = rx?.equation ?? (rx?.left || rx?.right ? JSON.stringify([rx.left, rx.right]) : null);
101
+ const electrons = said === null || /(^|[^\w])e-($|[^\w])/.test(said);
102
+ if (rx?.k0 > 10 && electrons) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
96
103
  });
97
104
  };
98
105
  (def.interfaces ?? []).forEach((f, k) => {
99
106
  if (!isObject(f)) return;
100
107
  capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
101
- kinetics(`interfaces[${k}]`, f.reactions);
108
+ // (an exchange current is an electrode's: a metal on one side, or sides not yet known; a
109
+ // splitting exciton's k0, between two semiconductors, is a velocity times cRef)
110
+ const sides = [def.regions?.[k], def.regions?.[k + 1]].map((r) => (isObject(r) ? def.materials?.[r.material] : undefined));
111
+ if (sides.some((m) => m === undefined || isObject(m?.conductor))) kinetics(`interfaces[${k}]`, f.reactions);
102
112
  });
103
113
  (def.ports ?? []).forEach((p, k) => {
104
114
  if (!isObject(p)) return;
@@ -149,7 +159,12 @@ export function describe(def) {
149
159
  return;
150
160
  }
151
161
  const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
152
- if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
162
+ // (donors and acceptors where there are electrons; an ion exchanger's charge in mol/m³)
163
+ const doped = species.some((sp, i) => mat.present[i] && (sp.name === 'e-' || sp.name === 'h+'));
164
+ if (reg.fixedCharge !== 0) {
165
+ const X = Math.abs(reg.fixedCharge) / FARADAY;
166
+ parts.push(doped ? `fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(X)}` : `fixed charge ${reg.fixedCharge > 0 ? '+' : '−'}${num(X)} mol/m³`);
167
+ }
153
168
  // The screening concentration: Σ z²c from the initial composition, plus the carriers that
154
169
  // balance the fixed charge (doping), which c0 doesn't list.
155
170
  let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
@@ -216,8 +231,34 @@ export function describe(def) {
216
231
  const cdef = def.contacts?.[side];
217
232
  const ct = model.contacts[side];
218
233
  const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
219
- const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
220
- lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
234
+ // (a bath without a reference species: its V is its φ)
235
+ const term = ct.she && !ct.she.reference ? ' (terminal: the bath φ)' : ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
236
+ // A bath read through a reference species: where the SHE level sits against its terminal,
237
+ // so a held electrode's V reads on the familiar scale (a Cl⁻ reference on table μ° puts it
238
+ // 1.37 V below, a surprise to anyone expecting 0 V to mean SHE).
239
+ let she = '';
240
+ if (ct.she?.reference) {
241
+ const d = ct.she.shift, at = Math.abs(d) < 5e-4 ? 'V' : `V ${d < 0 ? '−' : '+'} ${Math.abs(d).toFixed(3)} V`;
242
+ she = ct.she.exact ? `; the bath's SHE level is at ${at}` : `; the bath's φ is at ${at} (on the usual tables' μ°, where μ°_H⁺ = 0, that's its SHE level)`;
243
+ }
244
+ lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}${she}`);
245
+ // An outside phase out of equilibrium with a reaction that runs at the contact: the reaction
246
+ // then runs there for ever, a current at zero bias (H⁺ and OH⁻ given off Kw in a bath, say).
247
+ const end = regions[side === 'left' ? 0 : regions.length - 1].material;
248
+ model.reactions.forEach((rx, k) => {
249
+ if (!(rx.kf[end] > 0) || rx.kfProfile[end]) return; // (light, a profile, is meant to drive)
250
+ const parts = [...rx.reactants.map(({ i, nu }) => [i, -nu]), ...rx.products.map(({ i, nu }) => [i, nu])];
251
+ const level = (i) => {
252
+ const l = ct.species[i];
253
+ return l.type !== 'equilibrium' ? NaN : species[i].z === 0 ? l.mu : l.offset === undefined ? NaN : species[i].z * FARADAY * l.offset;
254
+ };
255
+ // A/RT from the outside's levels (the terminal voltage cancels: Σ ν z = 0)
256
+ const a = rx.fixedA - parts.reduce((sum, [i, nu]) => sum + nu * level(i), 0) / RT;
257
+ if (Math.abs(a) > 1e-3) {
258
+ const rdef = def.bulkReactions[k];
259
+ warnings.push(`contacts.${side}: the outside isn't in equilibrium with bulkReactions[${k}] (${rdef.equation ?? equation(rdef.nu)}; A = ${num(a)} RT there), so it runs at the contact for ever, a current even at zero bias. Unless that's meant (a source), give the outside a composition in equilibrium with it (H⁺ and OH⁻ at √K_w, say)`);
260
+ }
261
+ });
221
262
  }
222
263
  (def.ports ?? []).forEach((p, k) => {
223
264
  const port = model.ports[k];