driftlet 0.10.0 → 0.12.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/README.md CHANGED
@@ -13,35 +13,24 @@ to be the engine of live, thermodynamically honest web demos of charge transport
13
13
  semiconductors, electrochemical cells, membranes and solid ionic conductors. It handles any mix
14
14
  of charged and neutral species, Poisson electrostatics (or strict neutrality), bulk and
15
15
  interfacial reactions, heterointerfaces, metals and external circuits, in steady state, in time
16
- and as small-signal impedance. Every physics feature is validated against analytic results, and
17
- it's small enough to run in a web page: no dependencies, millisecond solves.
16
+ and as small-signal impedance. It's small enough to run in a web page: no dependencies,
17
+ millisecond solves.
18
18
 
19
19
  [Live demos](https://markblundeberg.github.io/driftlet/demos/) · [npm](https://www.npmjs.com/package/driftlet) ·
20
20
  [reading level diagrams](docs/visualization.md) · [device reference](docs/device.md) ·
21
21
  [agent guide](llms.txt)
22
22
 
23
+ **Built to be relied on.** A drift–diffusion solver that handles a textbook pn junction is a
24
+ weekend's work; one that keeps working on the devices people actually build is not. driftlet is
25
+ tested two ways: every physics feature against an analytic result or an independent code, and
26
+ thousands of randomly generated devices (junctions, MOS capacitors, solar cells, electrolyte
27
+ cells, electrodes) put through cold solves, sweeps, transients and impedance, every result
28
+ checked. A solve that fails reports it, with what went wrong where it can tell.
29
+ [How far to trust it](docs/reliability.md) has the details, the numbers and the known limits.
30
+
23
31
  **Status: 0.x.** Released on npm and used by the demos, but the API may still change between
24
32
  minor versions.
25
33
 
26
- ## Is it for your problem?
27
-
28
- The same equations go by different names in different fields. If your problem is one-dimensional
29
- (planar, or radial: spheres and cylinders, or any cross-section varying along x), it's probably here:
30
-
31
- | If you work on | you may call it | start from | checked against |
32
- |---|---|---|---|
33
- | Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions; thin-film transistors (gradual channel, in the [saturation demo](https://markblundeberg.github.io/driftlet/demos/saturation.html#transistor)) | [first example](#a-semiconductor-junction), [pn](https://markblundeberg.github.io/driftlet/demos/pn.html) and [MOS](https://markblundeberg.github.io/driftlet/demos/mos.html) demos, [`contacts`](test/contacts.test.js), [`metal`](test/metal.test.js) tests | exact built-in potential, Shockley J–V, depletion charge, MOS C–V |
34
- | Solar cells | photogeneration, radiative and SRH recombination | [solar](https://markblundeberg.github.io/driftlet/demos/solar.html) [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) and [perovskite (mobile ions)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) demos, [`ionmonger`](test/ionmonger.test.js), [`reactions`](test/reactions.test.js) and [`generation`](test/generation.test.js) tests: generation is a reaction from a photon reservoir (uniform, or Beer–Lambert with `photogeneration()`), SRH as a rate law, in the bulk and at faces | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
35
- | Electrochemistry, corrosion | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions, mixed potentials, electrodes under a film | [second example](#an-electrochemical-cell), [cyclic voltammetry](https://markblundeberg.github.io/driftlet/demos/redox.html), [Daniell cell](https://markblundeberg.github.io/driftlet/demos/daniell.html), [saturation](https://markblundeberg.github.io/driftlet/demos/saturation.html), [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js), [`junction`](test/junction.test.js), [`ports`](test/ports.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc; Wagner–Traud |
36
- | Batteries, intercalation | OCV, insertion hosts, chemical diffusion | [insertion demo](https://markblundeberg.github.io/driftlet/demos/insertion.html), [`statistics`](test/statistics.test.js) test | composition vs OCV, π²D/4L² relaxation |
37
- | Double layers, colloids | Poisson–Boltzmann, Gouy–Chapman–Stern, Debye screening, crowding (Bikerman) | [double-layer demo](https://markblundeberg.github.io/driftlet/demos/double-layer.html), [`equilibrium`](test/equilibrium.test.js) test | Gouy–Chapman charge and profile, Kilic–Bazant–Ajdari |
38
- | Membranes, desalination | Donnan, ion exchange, liquid junctions, water dissociation | [membrane demo](https://markblundeberg.github.io/driftlet/demos/membrane.html), [`equilibrium`](test/equilibrium.test.js), [`neutral`](test/neutral.test.js) tests | Donnan partition, Planck EMF |
39
- | Solid-state ionics | mixed ionic–electronic conduction, defect chemistry, mobile ions | [`statistics`](test/statistics.test.js), [`reactions`](test/reactions.test.js) tests | mass action from standard potentials |
40
- | Biophysics, nanofluidics | charged nanochannels and pores, resting potentials, Goldman–Hodgkin–Katz, Nernst, Donnan, pumps and leaks | [charged-nanochannel](https://markblundeberg.github.io/driftlet/demos/channel.html), [resting-potential](https://markblundeberg.github.io/driftlet/demos/cell.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) (a patch pipette's) demos, [`pore`](test/pore.test.js) test; a membrane as a face: a capacitor with ion permeabilities, and the Na⁺/K⁺ pump as a reaction on it ([`membrane`](test/membrane.test.js) test) | GHK potential and current–voltage curve; a resolved lipid layer; Mullins–Noda; the pump's static head; Donnan |
41
-
42
- The tests are worked setups, each with its analytic check, so they double as recipes. The
43
- [validation table](#validation) lists them all.
44
-
45
34
  ## Install
46
35
 
47
36
  ```sh
@@ -55,7 +44,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
55
44
  ```
56
45
 
57
46
  Or straight from a CDN in a page, pinned to a version:
58
- `https://cdn.jsdelivr.net/npm/driftlet@0.10.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
47
+ `https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
59
48
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
60
49
  [`llms.txt`](llms.txt).
61
50
 
@@ -143,7 +132,7 @@ them locally, serve the repository root (e.g. `python3 -m http.server`) and open
143
132
  | | | |
144
133
  |---|---|---|
145
134
  | [![pn junction](demos/screenshots/pn.png)](https://markblundeberg.github.io/driftlet/demos/pn.html) | [![solar cell](demos/screenshots/solar.png)](https://markblundeberg.github.io/driftlet/demos/solar.html) | [![MOS capacitor](demos/screenshots/mos.png)](https://markblundeberg.github.io/driftlet/demos/mos.html) |
146
- | **pn junction**: quasi-Fermi levels under bias; I–V against Shockley | **Solar cell**: J<sub>sc</sub> and V<sub>oc</sub> against collection theory | **MOS capacitor**: band bending; C–V against ideal theory |
135
+ | **pn junction**: quasi-Fermi levels under bias; I–V against Shockley | **Solar cell**: J<sub>sc</sub> and V<sub>oc</sub> against collection theory | **MOS capacitor**: band bending; C–V at any frequency, low to high |
147
136
  | [![cyclic voltammetry](demos/screenshots/redox.png)](https://markblundeberg.github.io/driftlet/demos/redox.html) | [![Daniell cell](demos/screenshots/daniell.png)](https://markblundeberg.github.io/driftlet/demos/daniell.html) | [![saturation](demos/screenshots/saturation.png)](https://markblundeberg.github.io/driftlet/demos/saturation.html) |
148
137
  | **Cyclic voltammetry**: a Fermi level against a redox level | **Daniell cell**: a real salt bridge, leaking, ion by ion | **Saturation**: Ag \| AgNO₃ \| Ag up to its limiting current, and a thin-film transistor pinching off, against the charge-sheet model |
149
138
  | [![impedance](demos/screenshots/impedance.png)](https://markblundeberg.github.io/driftlet/demos/impedance.html) | [![ion-exchange membrane](demos/screenshots/membrane.png)](https://markblundeberg.github.io/driftlet/demos/membrane.html) | [![double layer](demos/screenshots/double-layer.png)](https://markblundeberg.github.io/driftlet/demos/double-layer.html) |
@@ -152,6 +141,27 @@ them locally, serve the repository root (e.g. `python3 -m http.server`) and open
152
141
  | **Intercalation host**: cycling between cutoffs against the OCV | **Organic solar cell**: excitons diffusing to a donor/acceptor interface, against theory | **Perovskite hysteresis**: mobile ions and scan rate, against IonMonger |
153
142
  | [![charged nanochannel](demos/screenshots/channel.png)](https://markblundeberg.github.io/driftlet/demos/channel.html) | [![resting potential](demos/screenshots/cell.png)](https://markblundeberg.github.io/driftlet/demos/cell.html) | [![liquid junctions](demos/screenshots/junction.png)](https://markblundeberg.github.io/driftlet/demos/junction.html) |
154
143
  | **Charged nanochannel**: selectivity, overlapping Donnan layers and rectification, against Teorell–Meyer–Sievers | **Resting potential**: leaks and the Na⁺/K⁺ pump, against Mullins–Noda; the run-down to Donnan | **Liquid junctions**: a patch pipette's against Henderson, Planck and LJPcalc; a charged frit |
144
+ | [![action potential](demos/screenshots/axon.png)](https://markblundeberg.github.io/driftlet/demos/axon.html) | [![propagation](demos/screenshots/propagation.png)](https://markblundeberg.github.io/driftlet/demos/propagation.html) | [![myelin](demos/screenshots/myelin.png)](https://markblundeberg.github.io/driftlet/demos/myelin.html) |
145
+ | **Action potential**: Hodgkin and Huxley's gated channels, each ion's driving force through the spike, against the HH equations | **Propagation**: a spike travelling along an axon at HH's speed, out of the ions' drift; which ions carry it | **Myelin**: saltatory conduction, node to node, against a compartmental cable; a race with a bare axon |
146
+
147
+ ## Is it for your problem?
148
+
149
+ The same equations go by different names in different fields. If your problem is one-dimensional
150
+ (planar, or radial: spheres and cylinders, or any cross-section varying along x), it's probably here:
151
+
152
+ | If you work on | you may call it | start from | checked against |
153
+ |---|---|---|---|
154
+ | Semiconductor devices | drift–diffusion, van Roosbroeck, quasi-Fermi levels, Scharfetter–Gummel; pn, Schottky, MOS, heterojunctions; thin-film transistors (gradual channel, in the [saturation demo](https://markblundeberg.github.io/driftlet/demos/saturation.html#transistor)) | [first example](#a-semiconductor-junction), [pn](https://markblundeberg.github.io/driftlet/demos/pn.html) and [MOS](https://markblundeberg.github.io/driftlet/demos/mos.html) demos, [`contacts`](test/contacts.test.js), [`metal`](test/metal.test.js) tests | exact built-in potential, Shockley J–V, depletion charge, MOS C–V |
155
+ | Solar cells | photogeneration, radiative and SRH recombination | [solar](https://markblundeberg.github.io/driftlet/demos/solar.html) [organic (excitons)](https://markblundeberg.github.io/driftlet/demos/organic.html) and [perovskite (mobile ions)](https://markblundeberg.github.io/driftlet/demos/perovskite.html) demos, [`ionmonger`](test/ionmonger.test.js), [`reactions`](test/reactions.test.js) and [`generation`](test/generation.test.js) tests: generation is a reaction from a photon reservoir (uniform, or Beer–Lambert with `photogeneration()`), SRH as a rate law, in the bulk and at faces | `J_sc = qG(L_n + L_p + W)`; J_sc under Beer–Lambert against collection theory; Shockley J–V in the dark |
156
+ | Electrochemistry, corrosion | Nernst–Planck, concentration polarization, limiting current, Butler–Volmer, Warburg, cyclic voltammetry, salt bridges and liquid junctions, mixed potentials, electrodes under a film | [second example](#an-electrochemical-cell), [cyclic voltammetry](https://markblundeberg.github.io/driftlet/demos/redox.html), [Daniell cell](https://markblundeberg.github.io/driftlet/demos/daniell.html), [saturation](https://markblundeberg.github.io/driftlet/demos/saturation.html), [impedance](https://markblundeberg.github.io/driftlet/demos/impedance.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) demos, [`circuit`](test/circuit.test.js), [`kinetics`](test/kinetics.test.js), [`impedance`](test/impedance.test.js), [`junction`](test/junction.test.js), [`ports`](test/ports.test.js) tests | `i_lim·tanh(V/4V_T)`, Butler–Volmer closed form, finite-length Warburg; junction potentials against Henderson, Planck, JPCalc and LJPcalc; Wagner–Traud |
157
+ | Batteries, intercalation | OCV, insertion hosts, chemical diffusion | [insertion demo](https://markblundeberg.github.io/driftlet/demos/insertion.html), [`statistics`](test/statistics.test.js) test | composition vs OCV, π²D/4L² relaxation |
158
+ | Double layers, colloids | Poisson–Boltzmann, Gouy–Chapman–Stern, Debye screening, crowding (Bikerman) | [double-layer demo](https://markblundeberg.github.io/driftlet/demos/double-layer.html), [`equilibrium`](test/equilibrium.test.js) test | Gouy–Chapman charge and profile, Kilic–Bazant–Ajdari |
159
+ | Membranes, desalination | Donnan, ion exchange, liquid junctions, water dissociation | [membrane demo](https://markblundeberg.github.io/driftlet/demos/membrane.html), [`equilibrium`](test/equilibrium.test.js), [`neutral`](test/neutral.test.js) tests | Donnan partition, Planck EMF |
160
+ | Solid-state ionics | mixed ionic–electronic conduction, defect chemistry, mobile ions | [`statistics`](test/statistics.test.js), [`reactions`](test/reactions.test.js) tests | mass action from standard potentials |
161
+ | Biophysics, nanofluidics | charged nanochannels and pores, resting potentials, Goldman–Hodgkin–Katz, Nernst, Donnan, pumps and leaks | [charged-nanochannel](https://markblundeberg.github.io/driftlet/demos/channel.html), [resting-potential](https://markblundeberg.github.io/driftlet/demos/cell.html), [action-potential](https://markblundeberg.github.io/driftlet/demos/axon.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) (a patch pipette's) demos, [`pore`](test/pore.test.js) test; a membrane as a face: a capacitor with ion permeabilities, and the Na⁺/K⁺ pump as a reaction on it ([`membrane`](test/membrane.test.js) test); voltage-gated channels with Hodgkin–Huxley kinetics, action potentials, and their propagation along an axon ([`gates`](test/gates.test.js), [`cable`](test/cable.test.js) tests) | GHK potential and current–voltage curve; a resolved lipid layer; Mullins–Noda; the pump's static head; Donnan; HH gating under voltage clamp; the space-clamped HH equations |
162
+
163
+ The tests are worked setups, each with its analytic check, so they double as recipes. The
164
+ [validation table](#validation) lists them all.
155
165
 
156
166
  ## How to think about it
157
167
 
@@ -247,6 +257,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
247
257
  | Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
248
258
  | Charged pore or nanochannel (resolved Donnan layers at each mouth) | conductance against salt, down to its plateau, as Teorell–Meyer–Sievers in a long pore (short ones short by end layers ∝ 1/L); the zero-current voltage across a salt gradient; a symmetric pore's I–V odd, rectification with charge on one side or bipolar | 1%; 5e-3 to 1e-4; 1e-9 |
249
259
  | Cell membrane (a capacitive face with permeabilities) | GHK resting potential (neutral or resolved solutions) and current–voltage curve; the same as a resolved 5 nm lipid layer; charging at I/C; with the Na⁺/K⁺ pump in a closed cell, Mullins–Noda, the static head 3Δμ̄_Na − 2Δμ̄_K = ΔG_ATP, and Donnan with the pump off | 1e-5 V; 3e-3; 2e-5 V; 1%; 1e-9 |
260
+ | Voltage-gated channels (Hodgkin–Huxley gates on a membrane face) | each gate at α/(α + β) under a voltage clamp, and relaxing at α + β after a step; a squid axon's action potential against the space-clamped HH equations with GHK currents; its impedance at rest (inductive, resonant) against the linearised equations | 1e-12; 2e-4; 1 mV; 5e-4 |
261
+ | A propagating action potential (a squid axon along x: gated membrane port, ions drifting in the axoplasm) | rest at HH's −65 mV and gate values; conduction velocity against the cable equation solved directly, and HH's 18.8 m/s; a myelinated axon's saltatory conduction, node by node, against a compartmental cable | 0.01 mV, 1e-4; 2e-3; 1%; 1 µs of ~25 per node |
250
262
  | Liquid junctions in mixtures | Henderson's formula against JPCalc's published values; driftlet's steady junction against Planck by shooting and the stationary Nernst–Planck codes LJPcalc and JLJP, a patch pipette's K-gluconate among them; a free-diffusion junction constant in time, between the two | 0.015 mV; 2e-4 and 0.02 mV; 5e-3 mV |
251
263
  | Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
252
264
  | Salt diffusion mode from a `c0` profile | c̄ + a·sin(πx/2L) decaying at π²D/4L², D = 2D₊D₋/(D₊ + D₋) | 2e-5 |
@@ -260,12 +272,12 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
260
272
  | Time integration | BE first order, BDF2 second order; adaptive error control | ratios 2, 4 |
261
273
  | Advection and eddy mixing | exact convection–diffusion profile; `D + D_mix`; junction EMF unchanged by mixing | 1e-12; 1e-12; 1e-5 |
262
274
  | Metal regions | ohmic; Schottky face and MOS gate equal their contact forms; Ag \| AgNO₃ \| Ag with metal electrodes; bipolar electrode at V/2 | 1e-12; 1e-9; 1e-9; 1e-9 |
263
- | Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); held level; MOS low-frequency C–V with a grounded channel | 1e-4; 1e-9; 2e-3 |
275
+ | Internal ports | transmission line σV tanh(L/λ)/λ (O(h²)); held level; MOS low-frequency C–V with a grounded channel, and without one the same equilibrium, and a gate step filling the inversion layer to it | 1e-4; 1e-9; 2e-3; 1e-6; 1e-3 |
264
276
  | Cross-sections (spherical, cylindrical, any A(x)) | steady diffusion to a sphere and a cylinder (exact on any grid); Cottrell with the spherical term; uptake by a sphere filling from its surface; Debye–Hückel around a charged sphere, potential and charge | 1e-10; 3e-4; 2e-4; 1e-3, 3e-4 |
265
277
  | A capacitance through a port | a thin-film transistor against the charge-sheet model (below threshold to saturation, second order); de Levie impedance; gate impedance against dQ/dV | 5e-4; 1e-3; 1e-6 |
266
278
  | Electrode surfaces (coverage) | Langmuir isotherm against potential; the charge to fill a surface; the active–passive curve, blocking exactly (1 − θ) | 1e-9; 1e-3; 1e-9 |
267
279
  | Electrodes spread through a port | transmission line with the reaction's linear kinetics as the conductance (O(h²)), rates along it; Wagner–Traud mixed potential of two Butler–Volmer couples, floating | 1e-4, 2e-3; 1e-6 V |
268
- | Impedance | Macdonald blocking-electrode spectrum, 100 Hz–1 GHz; finite-length Warburg (Ag \| AgNO₃ \| Ag); DC limit = differential resistance | 3e-4; 3e-5; 1e-6 |
280
+ | Impedance | Macdonald blocking-electrode spectrum, 100 Hz–1 GHz; finite-length Warburg (Ag \| AgNO₃ \| Ag); DC limit = differential resistance; a p⁺n⁺ junction without recombination, Re Y → dI/dV and C = dQ/dV; a redox electrode against a bath at its open circuit, Re Y → dI/dV | 3e-4; 3e-5; 1e-6; 1e-4, 1e-6; 1e-4 |
269
281
  | Conservation | per step, and against time-integrated contact fluxes | 1e-11 relative |
270
282
  | Strictly neutral limit (ε = 0) | Planck EMF; polarization with no overlimiting; Donnan at neutral faces | 1e-5; 1e-4; 1e-9 |
271
283
  | Fermi–Dirac statistics | `𝓕_{±1/2}` vs quadrature; degenerate bulk; accumulation charge via `𝓕_{3/2}` | 1e-13; 1e-12; 1e-3 |
@@ -284,17 +296,17 @@ checked in CI; times are from a desktop Ryzen 7600X in Node 22:
284
296
  | Task | Time | Factorisations |
285
297
  |---|---|---|
286
298
  | Linear solve, 300 nodes × 7 unknowns (factor + solve) | 0.4 ms | 1 |
287
- | pn diode (264 nodes): cold equilibrium | 2 ms | 9 |
288
- | pn diode: I–V sweep 0 → 0.6 V, 31 points | 31 ms | 137 |
289
- | pn diode: warm jump from +0.4 V to −1 V | 7 ms | 51 |
290
- | pn diode: adaptive transient, 0 → 0.5 V, 100 ns | 150 ms | 1026 |
291
- | pn diode: impedance at 20 frequencies | 8 ms | 21 |
292
- | MOS with a metal gate: C–V sweep, 26 points | 11 ms | 126 |
293
- | n-Si \| KCl (4 species, 3 unknowns per node): Mott–Schottky sweep, 31 points | 29 ms | 156 |
294
- | n-Si \| KCl: impedance at 20 frequencies | 10 ms | 21 |
299
+ | pn diode (264 nodes): cold equilibrium | 3 ms | 9 |
300
+ | pn diode: I–V sweep 0 → 0.6 V, 31 points | 29 ms | 137 |
301
+ | pn diode: warm jump from +0.4 V to −1 V | 8 ms | 51 |
302
+ | pn diode: adaptive transient, 0 → 0.5 V, 100 ns | 165 ms | 926 |
303
+ | pn diode: impedance at 20 frequencies | 14 ms | 21 |
304
+ | MOS with a metal gate: C–V sweep, 26 points | 9 ms | 126 |
305
+ | n-Si \| KCl (4 species, 3 unknowns per node): Mott–Schottky sweep, 31 points | 36 ms | 156 |
306
+ | n-Si \| KCl: impedance at 20 frequencies | 16 ms | 21 |
295
307
  | Ag \| AgNO₃ \| Ag with double layers: sweep 0 → 0.1 V, 21 points | 12 ms | 81 |
296
- | Ag \| AgNO₃ \| Ag, neutral: adaptive transient over 1 s | 29 ms | 287 |
297
- | Silver electrodes with a bipolar plate: sweep 0 → 1 V, 11 points | 27 ms | 63 |
308
+ | Ag \| AgNO₃ \| Ag, neutral: adaptive transient over 1 s | 24 ms | 186 |
309
+ | Silver electrodes with a bipolar plate: sweep 0 → 1 V, 11 points | 25 ms | 54 |
298
310
 
299
311
  The library has no dependencies and does no DOM access, so it runs in a Web Worker. (A device
300
312
  definition is plain data and can be posted to a worker. Devices using custom-function
@@ -307,12 +319,14 @@ No dependencies. Tests use node's built-in runner (Node ≥ 22):
307
319
  ```sh
308
320
  npm test # the validation suite
309
321
  npm run bench # typical workloads against bench/baseline.json (--save to update it)
322
+ npm run stress # random devices, every result checked (--n 500 for more; see docs/reliability.md)
323
+ npm run hard # the corpus of once-hard cases against bench/hard.json
310
324
  npm run types # TypeScript declarations from the JSDoc, into types/ (fetches TypeScript via npx)
311
325
  npm run smoke # every demo in headless Chrome, every control to its extremes (CHROME=… to choose)
312
326
  npm run shots # retake the demos' screenshots, each caught at a set state (tools/shots.js)
313
327
  ```
314
328
 
315
- Design notes and plans: [numerics](docs/numerics.md), [conventions](docs/conventions.md),
329
+ Design notes and plans: [reliability](docs/reliability.md), [numerics](docs/numerics.md), [conventions](docs/conventions.md),
316
330
  [statistics](docs/statistics.md), [the kit](docs/kit.md), [reading level diagrams](docs/visualization.md),
317
331
  [data](docs/data.md), [roadmap](ROADMAP.md).
318
332
 
package/docs/device.md CHANGED
@@ -277,6 +277,68 @@ read from `sol.phi` (or a `'phi'` probe in a transient). A bath contact's termin
277
277
  its reference species' level instead, what an electrode reversible to that species would read,
278
278
  so between two different solutions it differs from $`\Delta\phi`$ by that species' Nernst term.
279
279
 
280
+ ### Voltage-gated channels
281
+
282
+ A permeability can be gated, as Hodgkin and Huxley's Na⁺ and K⁺ channels are: the face lists its
283
+ `gates`, each a fraction $`x`$ in [0, 1] with first-order kinetics in the voltage across the face,
284
+ $`V = \phi_{\mathrm{right}} - \phi_{\mathrm{left}}`$ at its two edge nodes,
285
+
286
+ ```math
287
+ \frac{dx}{dt} = \alpha(V)\,(1 - x) - \beta(V)\,x ,
288
+ ```
289
+
290
+ and a link names the gates that scale its $`P`$ (or a conductance link's $`G`$), with their exponents:
291
+ $`P\,m^3 h`$ for `{ m: 3, h: 1 }`. (A membrane along a region instead, an axon's, is a
292
+ [port](#a-membrane-through-a-window-gated-channels).)
293
+ Each of $`\alpha`$ and $`\beta`$ is one of NeuroML's three standard forms, with `rate` in 1/s and `midpoint`
294
+ and `scale` in volts:
295
+
296
+ | `type` | rate |
297
+ | --- | --- |
298
+ | `'exp'` | $`\mathtt{rate}\; e^{(V - \mathtt{midpoint})/\mathtt{scale}}`$ |
299
+ | `'sigmoid'` | $`\mathtt{rate} / (1 + e^{(\mathtt{midpoint} - V)/\mathtt{scale}})`$ |
300
+ | `'expLinear'` | $`\mathtt{rate}\; y/(1 - e^{-y})`$, $`y = (V - \mathtt{midpoint})/\mathtt{scale}`$ |
301
+
302
+ With the outside on the left, $`V`$ is the membrane potential $`\phi_{\mathrm{in}} - \phi_{\mathrm{out}}`$. Hodgkin and
303
+ Huxley's squid axon (at their 6.3 °C):
304
+
305
+ ```js nocheck
306
+ {
307
+ phi: { type: 'capacitive', C: 0.01 },
308
+ gates: {
309
+ m: { alpha: { type: 'expLinear', rate: 1000, midpoint: -0.040, scale: 0.010 }, beta: { type: 'exp', rate: 4000, midpoint: -0.065, scale: -0.018 } },
310
+ h: { alpha: { type: 'exp', rate: 70, midpoint: -0.065, scale: -0.020 }, beta: { type: 'sigmoid', rate: 1000, midpoint: -0.035, scale: 0.010 } },
311
+ n: { alpha: { type: 'expLinear', rate: 100, midpoint: -0.055, scale: 0.010 }, beta: { type: 'exp', rate: 125, midpoint: -0.065, scale: -0.080 } },
312
+ },
313
+ species: {
314
+ 'Na+': { type: 'permeability', P: 1.16e-6, gates: { m: 3, h: 1 } },
315
+ 'K+': { type: 'permeability', P: 1.33e-6, gates: { n: 4 } },
316
+ 'Cl-': { type: 'permeability', P: 6.4e-9 }, // the leak
317
+ 'A-': 'blocked',
318
+ },
319
+ }
320
+ ```
321
+
322
+ The gates scale permeabilities and nothing else: they hold no charge (the small gating current
323
+ of the channels' voltage sensors is left out, as Hodgkin and Huxley left it out), and each ion
324
+ still crosses by electrodiffusion down its own $`\bar\mu`$, so the dissipation stays positive whatever
325
+ they do. In steady state each gate is at $`\alpha/(\alpha + \beta)`$; each solution reports them as
326
+ `interfaces[f].gates` (`{ m, h, n }`) with the voltage they follow, `interfaces[f].V`.
327
+
328
+ Two things differ from the textbook equations. The currents are GHK's, which rectify: a channel's
329
+ conductance depends on the voltage, so a permeability matches a conductance $`g`$ at one voltage
330
+ only (above, the chord conductances at rest, −65 mV, for Hodgkin and Huxley's 120, 36 and 0.3
331
+ mS/cm²; matched at the reversal potentials instead, the rest depolarises itself and the axon
332
+ fires on its own). And a cell without a pump has no resting steady state: `solve()` finds its
333
+ Donnan equilibrium, every permeant ion level across the membrane, which the real cell reaches
334
+ only over hours. Start a transient from the concentrations given (it settles to rest within
335
+ milliseconds), or keep the gradients with the Na⁺/K⁺ pump on the same face. Started from the
336
+ same state, a squid axon's action potential follows the space-clamped Hodgkin–Huxley equations
337
+ with GHK currents to within 1 mV; and in small signals, with the membrane held at rest between
338
+ two baths, `impedance()` gives the gates' inductance that Cole measured (the impedance inductive
339
+ below ~50 Hz and resonant near 60 Hz), the linearised equations' to 5e-4
340
+ ([`gates`](../test/gates.test.js) test).
341
+
280
342
  ### Faces next to a conductor
281
343
 
282
344
  A conductor has no $`\phi`$, so a face beside it can't take a `dipole` or `step` alignment. Its $`\phi`$
@@ -432,6 +494,7 @@ any combination:
432
494
  | `reactions`, `area` | an [electrode spread through the window](#electrodes-spread-through-a-window) | a thin film on a metal, a crevice, a porous electrode |
433
495
  | `surface` | [species on that electrode's sites](#an-electrodes-surface-adsorbates-and-passive-films) | adsorbates, a passive film |
434
496
  | `capacitance`, `area` | [charge held across a capacitance](#a-capacitance-through-a-window) | a gate along a channel, a double layer |
497
+ | `gates` | [voltage-gated channels](#a-membrane-through-a-window-gated-channels) on its conductance links | an axon's membrane along it |
435
498
 
436
499
  ```js nocheck
437
500
  ports: [{
@@ -594,10 +657,20 @@ ports: [{ name: 'gate', region: 'channel', V: 1.5, area: 1 / 10e-9, capacitance:
594
657
  ports: [{ name: 'iron', region: 'film', terminal: 'e-', V: 0, area: 1e4, capacitance: { C: 0.2, zeroCharge: -0.4 }, reactions: [/* … */] }]
595
658
  ```
596
659
 
660
+ A volumetric capacitance $`C^*`$ (F/m³, an organic electrochemical transistor's) goes in per
661
+ area of a nominal electrode: `area: 1 / t, capacitance: { C: Cstar * t }` for a film t thick
662
+ (`area: 1, C: Cstar` is the same physics, but reads to `describe()` like a unit slip). To read the
663
+ gate's voltage as the film's effective one, put the undisturbed film at φ = 0 (its carrier's
664
+ `cRef` at its doping, `mu0: 0`) and leave `zeroCharge` at 0. The gate charges the carriers
665
+ through their own chemical capacitance too, in series, so $`dQ/dV_G`$ comes out below $`C^*`$
666
+ (about 12% for holes at 1e20 cm⁻³ against 40 F/cm³).
667
+
597
668
  That's the gradual-channel approximation of a field-effect transistor, exact for a thin film
598
669
  with no body (a TFT, an organic or oxide transistor) while the channel is long against the gate
599
670
  dielectric, and failing where it pinches off (where the 2D field takes over). It's also the
600
- Bernards model of an organic electrochemical transistor (a volumetric capacitance), cable theory
671
+ Bernards model of an organic electrochemical transistor (a volumetric capacitance; the
672
+ Bernards–Malliaras current is the charge-sheet formula below without its $`V_T`$ term, so they
673
+ part near pinch-off, where driftlet keeps the diffusion that formula drops), cable theory
601
674
  for an axon's membrane, and a porous electrode's double layer. The gate's flat level pins the
602
675
  channel's standard level through C, as a blocked spectator's flat level does in an electrolyte,
603
676
  and the current saturates as the carriers run out at the drain, in both.
@@ -608,6 +681,44 @@ below threshold through saturation (second order in the grid); a porous electrod
608
681
  impedance $`\sqrt{r/y}\coth(L\sqrt{ry})`$, the double layer in series with the ions' chemical
609
682
  capacitance; and the gate's low-frequency impedance against $`dQ/dV_G`$ from steady states.
610
683
 
684
+ ### A membrane through a window: gated channels
685
+
686
+ A port with a capacitance can carry [gates](#voltage-gated-channels), as a face can: at each node of
687
+ its window, each gate is a fraction open with Hodgkin–Huxley kinetics in the voltage across the
688
+ port's capacitance there, $`V_m = \phi - (V - \mathtt{zeroCharge})`$, and its conductance links name
689
+ the gates that scale them (`G × m³h`). That's a membrane all along a region, between it and an
690
+ outside held by the port: an axon's, with x running along the axon. Per volume of axoplasm, a
691
+ cylinder of radius a has $`2/a`$ of membrane (`area`); held at `V: 0` with `zeroCharge: 0`, the port's
692
+ V is the outside's $`\phi`$, and a charged species' `offset` is its level there against that,
693
+ $`(\mu^\circ + RT\ln(c_{out}/c_{\mathrm{ref}}))/(zF)`$. A conductance link's current is then
694
+ $`G\,(V_{i,out} - V_i)`$, which is $`g\,a_m (E_i - V_m)`$: Hodgkin and Huxley's linear channel, with $`E_i`$ the
695
+ Nernst potential at the inside's concentration there.
696
+
697
+ ```js nocheck
698
+ ports: [{
699
+ name: 'membrane', region: 'axon', V: 0, terminal: 'K+', area: 2 / 238e-6, // a squid giant axon
700
+ capacitance: { C: 0.01, zeroCharge: 0 },
701
+ gates: hodgkinHuxley({ /* … */ T }).gates,
702
+ species: {
703
+ 'Na+': { type: 'conductance', G: 1200 * 2 / 238e-6, offset: offsetNa, gates: { m: 3, h: 1 } },
704
+ 'K+': { type: 'conductance', G: 360 * 2 / 238e-6, offset: offsetK, gates: { n: 4 } },
705
+ 'Cl-': { type: 'conductance', G: 3 * 2 / 238e-6, offset: offsetCl }, // the leak
706
+ },
707
+ }]
708
+ ```
709
+
710
+ With the axoplasm strictly neutral and the outside held (no resistance outside), the ions'
711
+ drift along x and the membrane's charging are cable theory, without its being put in: a squid
712
+ axon's action potential propagates at Hodgkin and Huxley's speed, 18.73 m/s for their 18.5 °C
713
+ axon (they computed 18.8 by hand), the cable equation's own to 2e-3
714
+ ([`cable`](../test/cable.test.js) test). A myelinated axon is regions of one axoplasm, each node
715
+ of Ranvier and each internode its own, with a port each: the nodes' gated, the internodes' a small
716
+ capacitance (myelin) and nothing else. The grid refines at every node's faces, and the spike jumps
717
+ node to node, arriving at each within 1 µs of a compartmental cable's prediction (the same test).
718
+ Each solution's `ports[k]` reports the gates at each node of the window, `gates[name][j]`,
719
+ and the voltage they follow, `Vm[j]`. A gated window can't overlap another port's gated or
720
+ surfaced one.
721
+
611
722
  ### What a solution reports
612
723
 
613
724
  Each solution's `ports[k]`, as the port has them:
@@ -620,6 +731,7 @@ Each solution's `ports[k]`, as the port has them:
620
731
  | `rates[q][j]` | reaction q's forward rate at node j, mol/(m²·s) of electrode (reaction first, then node) |
621
732
  | `coverage[name][j]`, `bare[j]` | with a surface: each species' coverage θ, and the bare fraction θ₀ to full precision |
622
733
  | `sigma[j]`, `charge` | with a capacitance: σ per area of electrode (C/m²), and the total the port's side holds |
734
+ | `gates[name][j]`, `Vm[j]` | with gates: each gate's fraction open, and the voltage across the capacitance that they follow (V) |
623
735
 
624
736
  The arrays are typed (`Float64Array`), so `Array.from` them before mapping to anything but
625
737
  numbers. The kit's [`polarization()`](kit.md#polarization-curves-an-evans-diagram) gives each reaction's rate at a spot of the
@@ -636,7 +748,7 @@ contacts: {
636
748
  right: { I: -2, ... }, // driven by a current into the device, A/m² (I: 0 is open circuit)
637
749
  },
638
750
  ports: [{ name: 'ref', I: 0, ... }, // a reference electrode: no current, its voltage read off
639
- { name: 'wire', V: 0, R: 1e-3, ... }], // a source V behind a series resistance R (Ω·m²)
751
+ { name: 'wire', V: 0, R: 1e-3, ... }], // a source V behind a series resistance R (Ω·m², or Ω with a geometry)
640
752
  ```
641
753
 
642
754
  - **`V`**: the terminal's voltage, the shift of its outside phase's ladder, as the voltage of
@@ -703,7 +815,8 @@ its middle, and nothing passes the end. Only an end may have $`A = 0`$. Flow (`v
703
815
  planar devices.
704
816
 
705
817
  With a cross-section, the solution's currents, contact and port fluxes, terminal currents and
706
- `charge` are totals (A, mol/s; per metre for cylinders), and so are `I` drives. Face laws,
818
+ `charge` are totals (A, mol/s; per metre for cylinders), and so are `I` drives, a series `R`
819
+ and the impedance (Ω, not Ω·m²). Face laws,
707
820
  rates, `interfaces[f].N` and `D`, and concentrations stay per area or per volume. Validated against
708
821
  steady diffusion to a sphere and a cylinder (exact), Cottrell's transient with the spherical term,
709
822
  uptake by a sphere filling from its surface, and Debye–Hückel screening around a charged sphere
@@ -715,6 +828,7 @@ uptake by a sphere filling from its surface, and Debye–Hückel screening aroun
715
828
  const dev = new Device(def);
716
829
  const sol = dev.solve(); // steady state (or equilibrium)
717
830
  dev.set({ contacts: { right: { V: 0.3 } } }); // deep-merged change; the state is kept as a warm start
831
+ dev.set({ ports: { gate: { V: 0.2 } } }); // ports patched by name (an array replaces them all)
718
832
  const sol2 = dev.solve();
719
833
  const tr = dev.step(1e-6); // one backward-Euler step (auto-subdivided if needed)
720
834
  const tr2 = dev.step(1e-6, { method: 'bdf2' }); // second-order BDF2 once a previous step exists
@@ -729,22 +843,30 @@ const now = dev.solution(); // snapshot of the current state
729
843
  amounts (blocked species, reactive moieties) are kept exactly. If a direct solve fails at a
730
844
  bias (a cold start far from equilibrium), it solves with both terminals level and ramps the
731
845
  right terminal's voltage to its target; with generation reactions (below), it ramps their
732
- rates up from nearly nothing.
846
+ rates up from nearly nothing. A terminal driven by a current (open circuit, say) is held at a
847
+ voltage instead, the voltage marched until the current crosses its target, and floated from
848
+ there. If 20 V either way brings no crossing (a current beyond the limiting one), the solve
849
+ fails with a warning giving the range of currents the held voltages passed.
733
850
  - `step(dt, { method })` advances the transient by dt seconds, halving internally where Newton
734
851
  needs it. `method` is `'be'` (backward Euler, the default) or `'bdf2'`.
735
852
  - `advance(tEnd, opts)` integrates adaptively to `tEnd` with variable-step BDF2, controlling
736
853
  the local error per step to `tol` (default 1e-3) in thermal units of every potential ($`\phi`$ and
737
- each $`\bar\mu/RT`$): roughly 0.1% in concentrations. It lands exactly on `tEnd`. Options: `tol`,
854
+ each $`\bar\mu/RT`$): roughly 0.1% in concentrations per step. That's local: over a long decay
855
+ the errors add up, and a small current at the end of one can be tens of percent off, so tighten
856
+ `tol` (1e-5, say) before fitting a time constant to a tail. It lands exactly on `tEnd`. Options: `tol`,
738
857
  `dt0` (first step), `dtMax`, `budgetMs` (return after this much wall time, with
739
858
  `done: false`), `maxSteps`, `method`. The step size carries over between calls, so an
740
859
  animation can call `advance(tNext, { budgetMs })` once per frame. The solution adds `done`,
741
860
  `rejected`, and a `trace` of terminal current and voltage after every accepted step (the
742
- voltage is the right contact's minus the left's: what a meter between the terminals reads,
861
+ current is the right contact's, toward +x, as `sol.current`; a port's is in `sol.terminals`
862
+ after each call; the voltage is the right contact's minus the left's: what a meter between the terminals reads,
743
863
  which at a bath is its reference species' level, not $`\phi`$). With
744
864
  `probes: [{ x, species, quantity, region }]` the trace also reads inside the device,
745
865
  as `trace.probes[k]` beside `trace.t`: a species' concentration (`quantity: 'c'`, the default,
746
866
  mol/m³) or species voltage (`'V'`), or $`\phi`$ itself (`'phi'`, no species), linearly between the
747
- nodes around `x` (at an interface, `region` picks the side). That's what a detector at `x` sees. Probes read at accepted steps, which grow
867
+ nodes around `x` (at an interface, `region` picks the side). That's what a detector at `x` sees.
868
+ At a face, `{ interface: f, species }` reads a species' flux through it (mol/(m²·s), toward +x,
869
+ as `interfaces[f].N`), and `{ interface: f, gate }` a gate's fraction open. Probes read at accepted steps, which grow
748
870
  as a transient slows, so give `dtMax` to resolve a signal in time.
749
871
  - `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
750
872
  it: $`Z(f) = \delta V/\delta I`$ in Ω·m² at one terminal (`'right'` by default), with I into the
package/docs/kit.md CHANGED
@@ -142,6 +142,18 @@ Concentrations are held, so these are the curves of that instant (for a mixed po
142
142
  kinetic picture). Away from the actual level the composition beside the electrode would change,
143
143
  which is the transport the full solve accounts for.
144
144
 
145
+ ## Channels
146
+
147
+ **`hodgkinHuxley({ inside, outside, T, g, leak, leakZ, at, names })`**: Hodgkin and Huxley's
148
+ squid-axon channels for a [membrane face](device.md#voltage-gated-channels) with the outside on its
149
+ left: `{ gates, species }`, the face's `gates` (m, h and n, their 1952 rates at 6.3 °C scaled to
150
+ `T` by Q₁₀ = 3) and links for Na⁺ (P·m³h), K⁺ (P·n⁴) and the leak (one ion, `leak`, of charge
151
+ `leakZ`: Cl⁻ by default). The currents are GHK's, which rectify, so each permeability is the one
152
+ whose chord conductance at `at` (−65 mV) is HH's `g` (120, 36 and 0.3 mS/cm²) at the
153
+ concentrations given. Spread it into the face beside its $`\phi`$ law, adding the other species'
154
+ links: `{ phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } }`.
155
+ `ghkCurrent(z, inside, outside, V, T)` is the GHK current (outward, A/m²) at unit permeability.
156
+
145
157
  ## Sources
146
158
 
147
159
  Helpers that write sources as plain data: