driftlet 0.12.0 → 0.14.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/llms.txt CHANGED
@@ -1,72 +1,149 @@
1
1
  # driftlet: a guide for agents
2
2
 
3
3
  driftlet is a dependency-free JavaScript library that solves 1D drift–diffusion–reaction
4
- (Poisson–Nernst–Planck) problems fast enough for live web demos: semiconductor junctions,
5
- electrochemical cells, double layers, membranes, mixed conductors. It runs in a browser, a Web
4
+ (Poisson–Nernst–Planck) problems fast enough for live web demos. Semiconductor junctions, solar
5
+ cells, electrochemical cells, batteries, membranes and nerves are one problem with different
6
+ casts of species (electrons, holes, ions, neutral molecules), each moving down its own
7
+ electrochemical potential μ̄, coupled by Poisson's equation, reactions and interfaces. So a
8
+ device in any field is built the same way, from the same pieces. It runs in a browser, a Web
6
9
  Worker or Node, from npm or a CDN.
7
10
 
8
- This guide is for writing a demo or calculation with it. Every code block below runs in the
9
- test suite. The full references are docs/device.md (the definition), docs/kit.md (the helpers),
10
- docs/conventions.md (signs and levels) and docs/data.md (the data library).
11
+ Every code block below runs in the test suite and prints its closed-form check. Skim the next
12
+ two sections, then go to the template nearest your device:
13
+
14
+ - semiconductors: a pn junction (with an I–V sweep), a live page with a slider, a solar cell, a
15
+ MOS capacitor, a Schottky diode, a thin-film transistor, a pulse drifting along a bar (under
16
+ Transients);
17
+ - electrochemistry: a cell, an electrode in a stirred solution (a potential step, and a cyclic
18
+ voltammogram), corrosion;
19
+ - physiology: a cell membrane (a resting potential), a voltage clamp, an action potential, one
20
+ travelling along an axon.
21
+
22
+ The references are in docs/: device.md (the definition), kit.md (the helpers), conventions.md
23
+ (signs and levels), visualization.md, alignment.md (band offsets), statistics.md (non-ideal
24
+ materials, insertion hosts), data.md and reliability.md (what's validated, and the limits).
11
25
 
12
26
  ## Imports
13
27
 
14
- From a CDN, pin the version:
28
+ From a CDN, pin the version you tested against:
15
29
 
16
30
  ```js nocheck
17
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/plot.js';
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';
20
34
  ```
21
35
 
22
- From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
36
+ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
23
37
 
24
38
  ## What to know first
25
39
 
26
- - **Units are SI**: m, mol/m³ (1 M = 1000 mol/m³), J/mol, m²/s, V, A/m², F/m², C/m³. Use
27
- `units` to convert: `units.um(1)`, `units.perCm3(1e17)` (carriers per cm³ to mol/m³),
28
- `units.eV(1.12)` (to J/mol), `units.cm2PerS(36)`, `units.molar(0.1)`.
29
- - **Everything you set or read is an electrochemical potential μ̄** (or a voltage made from one).
30
- A terminal's `V` is the voltage of its terminal species: for an electron contact,
31
- V = −μ̄_e/F, what a voltmeter reads. φ is bookkeeping and never a control.
32
- - **Solutions** give, per species, `c` (concentration), `mu` (μ̄), `V` (the species voltage
33
- μ̄/(zF), a quasi-Fermi level for e⁻ and h⁺) and `Vstd` (the standard level, a band edge for e⁻
34
- and h⁺). In voltage units the conduction band sits below the valence band. `NaN` marks where a
35
- species is absent.
36
- - **Current**: `sol.current` is positive toward +x through the device. `sol.terminals[name]` gives
37
- each terminal's `{ V, current }`, current into the device. Currents, fluxes and amounts are per
38
- m² of a planar device; with a `geometry` (spherical, cylindrical or any cross-section A(x)) they
39
- are totals through it.
40
- - **Nothing physical is defaulted.** Every interface between different materials needs an
41
- alignment (`dipole` or `step`), or `phi: 'neutral'` for a macroscopic model. Every metal face
42
- next to a dielectric needs its φ law. Every species that doesn't reach a contact needs its
43
- initial amount (`c0`). An error names the path and says what to give.
44
- - **Check before trusting.** `sol.converged` must be true, and read `sol.warnings`.
45
- `describe(def)` (kit) summarises a definition and flags likely unit slips. Once a device
46
- works, run `check(dev, sol)` (kit) and show or log its `text`: each species' ledger (what
47
- every terminal and reaction brings in or uses, summing to zero: J = F(G − R) under light),
48
- conservation in transients, and the same device on a grid twice as fine (a FAIL means the
49
- answer depends on the grid: refine it). It costs a solve, so run it once, not per frame.
50
- - **Exports.** `driftlet`: `Device`, `units`, and the constants `FARADAY`, `GAS_CONSTANT`,
51
- `EPS0`, `BOLTZMANN`, `ELEMENTARY_CHARGE`, `AVOGADRO` (there's no thermal-voltage constant:
52
- V_T = GAS_CONSTANT·T/FARADAY). `driftlet/kit`: `build`, `layer`, `ohmic`, `bath`, `half`,
53
- `level`, `SHE`, `describe`, `live`, `traces`, the sources (`pulse`, `square`, `triangle`, `ramp`,
54
- `injector`, `recombination`, `photogeneration`), the data library (`aqueous`, `semiconductor`,
55
- `metal`, `IONS`, …), `check` (a solution's checks), `polarization` (an electrode's reactions at hypothetical metal levels, with the solution held: an Evans diagram), `henderson` and `planck` (liquid-junction potentials), `recorder` (a transient's trace and frames across calls), the curated
56
- `perovskiteCell` with IonMonger's `PEROVSKITE_SCANS` and `hysteresis`, and the vacuum-level helpers. `driftlet/plot` (a quick look; `traces()` is the data for your own charts): `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`), `themeColor`.
57
- - **Reactions** are written as equations, `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, in
58
- `new Device`, `build()` and `set()` alike. A coefficient is a number and a space (`2 e-`).
59
- - **Rate constants carry units.** A bulk reaction's rate is `kf · Π c_reactants` in mol/(m³·s),
60
- with c in mol/m³ (and 1e16 cm⁻³ is only 0.0166 mol/m³). Band-to-band recombination,
61
- `e- + h+ = 0`, gives a minority lifetime τ = 1/(kf·N) with N the majority density in mol/m³:
62
- `kf: 1e8` is τ ≈ 0.6 µs at 1e16 cm⁻³. Trap-assisted (SRH) recombination is
63
- `{ equation: 'e- + h+ = 0', srh: { Si: { tauN, tauP } } }` (lifetimes in s), and at a face
64
- `{ equation: 'e-(left) + h+(right) = 0', srh: { vn, vp } }` (velocities in m/s); both default to a
65
- midgap trap. Generation is a reaction from a reservoir, rate `kf` in
66
- mol/(m³·s) (`units.perCm3` converts cm⁻³·s⁻¹ too); light absorbed as it goes in is
67
- `photogeneration({ material, flux, alpha, mu: units.eV(3), from, to })` (flux in
68
- mol/(m²·s), α in 1/m), a `kf` that varies with x. A face reaction's `k0` is in mol/(m²·s);
69
- its exchange current density is F·k0 per electron at the reference concentrations.
40
+ - **Units are SI**: m, mol/m³ (1 M = 1000 mol/m³; 1e16 cm⁻³ is only 0.0166 mol/m³), J/mol, m²/s,
41
+ V, A/m², F/m², S/m². `units` converts in (`units.um(1)`, `units.perCm3(1e17)` for per cm³ or
42
+ per cm³·s, `units.eV(1.12)` to J/mol, `units.cm2PerS(36)`, `units.molar(0.1)`) and back
43
+ (`units.toPerCm3`, `toEV`, `toMolar`, `toUm`, …). Physiology: 1 mS/cm² = 10 S/m², 1 µF/cm² =
44
+ 0.01 F/m², 1 µA/cm² = 0.01 A/m², mM = mol/m³.
45
+ - **Everything you set or read is an electrochemical potential μ̄** (or a voltage made from one). A
46
+ terminal's `V` is the level of its terminal species (`terminal`, which the kit's contacts set):
47
+ for an electron contact V = −μ̄_e/F, what a voltmeter reads. φ is bookkeeping, per material, and
48
+ never a control.
49
+ - **Solutions** are plain objects of `Float64Array`s: `x`, `phi`, and per species `c`, `mu` (μ̄),
50
+ `V` (the species voltage μ̄/(zF): a quasi-Fermi level for e⁻ and h⁺) and `Vstd` (the standard
51
+ level: a band edge; in volts, E = −V, so E_c is the lower line). `NaN` marks a value
52
+ that doesn't exist (a species absent from a material, `V` of a neutral one, `c` in a metal).
53
+ - **Currents.** `sol.terminals[name]` gives each terminal's `{ V, current }`, its current into
54
+ the device; a current drive `{ I }` is the same way round (electrons fed in are I < 0).
55
+ `sol.current` is the right contact's toward +x, so a forward diode with its p side on the right
56
+ carries a negative one (with ports, read each contact from `sol.terminals`). Everything is per
57
+ m² of a planar device; with a `geometry` (shells, or any cross-section A(x)) it's a total.
58
+ - **What isn't defaulted**: an alignment between different materials, a φ law where a metal
59
+ meets a dielectric, and the amount of anything a contact doesn't feed (`c0`). The error names
60
+ the path and says what to give. (Kinetic details are defaulted: listed in docs/device.md.)
61
+ - **Check before trusting.** `sol.converged` must be true; read `sol.warnings`, where a failed
62
+ solve always says why. `describe(def)` (kit) summarises a definition and flags likely unit
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).
66
+
67
+ ## How a device is written
68
+
69
+ - **`build({ T, library, stack, bulkReactions, ports, grid, geometry })`** (kit) reads a stack
70
+ left to right: its first and last items are the contacts, `layer(material, length, opts)`s
71
+ are regions (give one a `name` to refer to it), and any other object between two layers is
72
+ the face between them. `ohmic()`, `bath()` and the like write contacts; a plain object works.
73
+ - **The library** is pieces with `species` and `materials`: `semiconductor('Si')` (Si, Ge, GaAs, at
74
+ 300 K), `aqueous(names)` (H⁺ Li⁺ Na⁺ K⁺ Ag⁺ Cu²⁺ Zn²⁺ Fe²⁺ Fe³⁺ OH⁻ Cl⁻ NO₃⁻ SO₄²⁻ in water, at 25
75
+ °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: {} } } }`.
84
+ - **Faces.** Between two different non-metals, give an alignment, `{ dipole }` or `{ step: {
85
+ species: 'e-', value } }` (J/mol), or `phi: 'neutral'`. `vacuumDipole(def, { material, anchor:
86
+ 'e-', offset: χ }, …)` estimates a dipole from electron affinities (Anderson's rule); it reads
87
+ only the materials, so build with `{ dipole: 0 }`, then put its value in (docs/alignment.md). Next
88
+ to a metal, a face takes no alignment, but needs a φ law if the other side has ε > 0: `{ phi: {
89
+ type: 'capacitive', C }, zeroCharge }` or `phi: 'neutral'`, where `zeroCharge` is the metal's V −
90
+ φ at its edge when the face holds no charge (a work-function difference, a potential of zero
91
+ charge, a Schottky barrier). A face's reactions label sides: `'Li+(left) = Li+(right)'`.
92
+ - **Ports** are terminals attached sideways to a window inside a region (`from`, `to`, m from its
93
+ left end): a source or drain, an injector, a reference electrode (`I: 0`), an electrode spread
94
+ through a film (`reactions`, `area`), a gate along a channel or a membrane along an axon
95
+ (`capacitance`, `gates`). A BJT's base contact is a port on a narrow window of the base,
96
+ `{ region: 'base', from, to, V, terminal: 'h+', species: { 'h+': 'equilibrium' } }`.
97
+ - **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 }`
99
+ (piecewise linear; a step is two points at one time), or the kit's `pulse({ start, width,
100
+ amplitude, rise, base })`, `square({ high, low, period, duty })`, `ramp({ from, to, duration,
101
+ start })` or `triangle({ from, to, period })` (out and back each period from t = 0: a cyclic
102
+ voltammogram, in terminal volts). `dev.set({ contacts: { right: { V } } })` changes a drive
103
+ and keeps the state as a warm start; ports are patched by name, `{ ports: { gate: { V } } }`.
104
+ - **Reactions** are equations: `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, a coefficient
105
+ written with its space (`2 e-`). A participant that isn't a species (solid silver) is fixed by
106
+ its μ: `fixed: { 'Ag(s)': 0 }`. A bulk reaction's forward rate is `kf · Π c_reactants`
107
+ (mol/(m³·s)); the backward one follows from the μ°.
108
+ - **Recombination and light.** Band-to-band `e- + h+ = 0` gives a minority lifetime τ =
109
+ 1/(kf·N), N the majority density in mol/m³ (`kf: 1e8` is 0.6 µs at 1e16 cm⁻³); being
110
+ bimolecular, as is the kit's `recombination()`, it shortens at high injection. SRH is `srh: { Si:
111
+ { tauN, tauP } }` in place of `kf` (s), or at a face `{ equation: 'e-(left) + h+(right) = 0',
112
+ srh: { vn, vp } }` (m/s); with τ_N ≪ τ_p it gives n-type silicon's holes a lifetime τ_p at any
113
+ 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 })`.
116
+ - **Electrode kinetics.** A face reaction's `k0` (mol/(m²·s)) is its rate with every participant
117
+ 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
+ `k0: 10`).
119
+ - **Grids** grade geometrically from `hmin` at each region's ends up to `hmax`, neighbouring cells
120
+ differing by at most `ratio` (1.2): put `hmin` below the shortest length that matters (a Debye
121
+ length, a depletion edge). `check()` says whether it's fine enough.
122
+
123
+ `build()` is the usual way in. It writes a plain definition for `new Device()`, which some
124
+ templates below write by hand (where a port spans a region, or no stack fits):
125
+
126
+ ```js
127
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
128
+
129
+ // A concentration cell: 100 mM KCl against 10 mM through a gap, read by Ag/AgCl electrodes.
130
+ const D = { 'K+': 1.957e-9, 'Cl-': 2.032e-9 };
131
+ const dev = new Device({
132
+ T: 298.15,
133
+ species: [{ name: 'K+', z: 1, cRef: 1000 }, { name: 'Cl-', z: -1, cRef: 1000 }], // cRef: the standard state, mol/m³
134
+ materials: { water: { epsr: 0, species: { 'K+': { D: D['K+'], mu0: -283.27e3 }, 'Cl-': { D: D['Cl-'], mu0: -131.23e3 } } } },
135
+ regions: [{ name: 'gap', material: 'water', length: 100e-6, c0: { 'K+': 10, 'Cl-': 10 } }], // left to right
136
+ // (interfaces: [...] would hold the faces between regions, one fewer than the regions)
137
+ contacts: {
138
+ left: { V: 0, bath: { c: { 'K+': 100, 'Cl-': 100 }, reference: 'Cl-' } }, // V is Cl⁻'s level: an Ag/AgCl electrode's
139
+ right: { I: 0, bath: { c: { 'K+': 10, 'Cl-': 10 }, reference: 'Cl-' } }, // open circuit
140
+ },
141
+ grid: { hmin: 0.1e-6, hmax: 5e-6 },
142
+ });
143
+ const sol = dev.solve();
144
+ const tK = D['K+'] / (D['K+'] + D['Cl-']), VT = (GAS_CONSTANT * 298.15) / FARADAY;
145
+ 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
+ ```
70
147
 
71
148
  ## Template: a semiconductor junction
72
149
 
@@ -97,45 +174,69 @@ for (const V of [0, 0.2, 0.4, 0.6]) {
97
174
  }
98
175
  ```
99
176
 
100
- ## Template: an electrochemical cell
177
+ ## Template: a live page with a slider
101
178
 
102
- An electrode is a metal layer with the reaction at its face, behind a contact holding the
103
- metal's electrons (`ohmic(V, ['e-'])`). Reactions are written as equations. A participant that
104
- isn't a species (solid silver) is fixed by its μ.
179
+ `live()` warm-starts each solve, merges slider moves made during one, and keeps the last good
180
+ solution (on a failure, `info.failed` and `info.error` are set). It returns `{ set, solution, ready,
181
+ close }`; `set()` and `ready` (the first solve) are promises of `{ solution, info }`.
182
+ `bandDiagram()` returns an SVG string; a fixed `range` keeps its axis still. `worker: true` solves
183
+ in a Web Worker, from a CDN too. A few hundred nodes solve in milliseconds; thousands, or
184
+ transients, want a worker and `advance(t, { budgetMs })` per frame. `onsolution` may call `set()`,
185
+ if that converges rather than oscillates. (A headless browser screenshotting a page that loads from
186
+ a CDN needs a long virtual-time budget, 30 s.)
105
187
 
106
- ```js
107
- import { Device } from 'driftlet';
108
- import { build, layer, ohmic, half, level, SHE, aqueous, metal } from 'driftlet/kit';
188
+ ```html
189
+ <!doctype html>
190
+ <meta charset="utf-8">
191
+ <title>pn junction</title>
192
+ <label>bias <input id="V" type="range" min="-0.5" max="0.7" step="0.01" value="0"></label>
193
+ <span id="readout"></span>
194
+ <div id="figure"></div>
195
+ <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';
109
199
 
110
- const silver = half('Ag+ + e- = Ag(s)', { 'Ag(s)': 0 }); // μ of the element: 0 on table conventions
111
- // A Stern layer at each metal face: its capacitance and the potential of zero charge are
112
- // properties of that interface, so they're yours to choose.
113
- const face = { phi: { type: 'capacitive', C: 0.2 }, zeroCharge: 0.1, reactions: [{ ...silver, k0: 1e-3, alpha: 0.5 }] };
114
200
  const def = build({
115
- library: [aqueous(['H+', 'Ag+', 'NO3-']), metal('Ag')],
116
- stack: [
117
- ohmic(0, ['e-']),
118
- layer('Ag', 1e-6),
119
- face,
120
- layer('water', 20e-6, { c0: { 'H+': 1, 'Ag+': 10, 'NO3-': 11 } }),
121
- face,
122
- layer('Ag', 1e-6),
123
- ohmic(0.05, ['e-']),
124
- ],
125
- grid: { hmin: 0.1e-9, hmax: 200e-9, ratio: 1.15 },
201
+ T: 300,
202
+ library: [semiconductor('Si')],
203
+ stack: [ohmic(0), layer('Si', 1e-6, { donors: units.perCm3(1e17) }), layer('Si', 1e-6, { acceptors: units.perCm3(1e16) }), ohmic(0)],
204
+ bulkReactions: [{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }],
205
+ grid: { hmin: 1e-9, hmax: 20e-9 },
126
206
  });
127
- const sol = new Device(def).solve();
128
- const mid = sol.x.length >> 1;
129
- console.log(`I = ${sol.current.toFixed(2)} A/m²`);
130
- // The silver couple's redox level, and the SHE level, mid-cell (electron voltages).
131
- console.log(level(sol, silver)[mid] - level(sol, SHE, { standard: true })[mid]); // ≈ 0.80 + (RT/F) ln(c/1 M)
207
+ const slider = document.getElementById('V');
208
+ const dev = live(def, {
209
+ onsolution(sol, info) {
210
+ document.getElementById('figure').innerHTML = bandDiagram(sol, { energy: true, range: [-1.2, 1.7], title: `V = ${sol.terminals.right.V.toFixed(2)} V` });
211
+ document.getElementById('readout').textContent = info.failed ? 'no solution here' : `I = ${sol.current.toExponential(2)} A/m²`;
212
+ },
213
+ });
214
+ slider.addEventListener('input', () => dev.set({ contacts: { right: { V: +slider.value } } }));
215
+ </script>
132
216
  ```
133
217
 
218
+ To draw in your own style, `traces(sol, opts)` (kit, the same options) gives the lines as data;
219
+ see "Reading level diagrams" below.
220
+
221
+ For an explainer, `particles(sol, { weight, cap })` (kit) draws a solution as dots, each `weight`
222
+ 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
+
134
235
  ## Template: a solar cell
135
236
 
136
- Light is a reservoir: `photon = e- + h+` with the photons' μ well above the gap generates at
137
- the rate `kf` (uniform in the material). Open circuit is a terminal driven at `I: 0`, whose
138
- voltage is then V_oc. The photocurrent flows toward +x here (holes to the p side on the right).
237
+ `photon = e- + h+`, with the photons' μ well above the gap, generates uniformly at the rate `kf`.
238
+ Open circuit is `I: 0`, and the terminal's V is then V_oc. The photocurrent flows toward +x
239
+ (holes to the p side, on the right).
139
240
 
140
241
  ```js
141
242
  import { Device, units } from 'driftlet';
@@ -162,111 +263,170 @@ const oc = new Device(cell({ I: 0 })).solve(); // open circuit
162
263
  console.log(`J_sc = ${sc.current.toFixed(1)} A/m², V_oc = ${oc.terminals.right.V.toFixed(3)} V`);
163
264
  ```
164
265
 
165
- ## Template: a cell membrane
266
+ For a perovskite cell, prefer the curated `perovskiteCell({ V, light, bulkSRH })`: IonMonger's
267
+ default cell (mobile iodide vacancies), with its J–V scans at seven rates in `PEROVSKITE_SCANS`
268
+ and `hysteresis(trace, turn)` to read the same figures from yours.
269
+
270
+ ## Template: a MOS capacitor
166
271
 
167
- A lipid bilayer is a face: a capacitor (about 1 µF/cm², 0.01 F/m²) that ions cross through
168
- channels, each with a permeability (`{ type: 'permeability', P }`, m/s: electrodiffusion in a
169
- constant field, so Goldman–Hodgkin–Katz comes out of it), and that pumps cross as reactions with
170
- saturating kinetics (`vmax`, `K`). The solutions on either side can be strictly neutral. The
171
- membrane potential is φ_in − φ_out from `sol.phi` (a bath's terminal voltage reads its reference
172
- species' level instead, which differs from Δφ by that ion's Nernst term).
272
+ The gate is a metal layer with a capacitive face to the oxide (a metal screens within Å, so C is
273
+ large) and the gate's alignment as `zeroCharge`. In 1D nothing supplies the inversion layer's
274
+ electrons (a MOSFET's source and drain do), so a port holds their level at the surface: the
275
+ low-frequency C–V. The gate's charge is the displacement into the oxide, `sol.interfaces[0].D`.
173
276
 
174
277
  ```js
175
- import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
176
- import { build, layer, bath, aqueous, check } from 'driftlet/kit';
278
+ import { Device, units, EPS0, FARADAY, GAS_CONSTANT } from 'driftlet';
279
+ import { build, layer, ohmic, semiconductor, metal, vacuumZeroCharge } from 'driftlet/kit';
177
280
 
178
- const T = 310.15, VT = (GAS_CONSTANT * T) / FARADAY;
179
- const out = { 'Na+': 145, 'K+': 5, 'Cl-': 110, 'A-': 40 }; // mM = mol/m³; A⁻: anions that don't cross
180
- const inside = { 'Na+': 12, 'K+': 140, 'Cl-': 10, 'A-': 142 };
181
- const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 }); // strictly neutral solutions
182
- water.species.push({ name: 'A-', z: -1, cRef: 1000 });
183
- water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
184
- const P = { 'K+': 1e-8, 'Na+': 0.04e-8, 'Cl-': 0.45e-8 }; // m/s, Hodgkin and Katz's ratios
185
- const membrane = {
186
- phi: { type: 'capacitive', C: 0.01 },
187
- species: { 'K+': { type: 'permeability', P: P['K+'] }, 'Na+': { type: 'permeability', P: P['Na+'] }, 'Cl-': { type: 'permeability', P: P['Cl-'] }, 'A-': 'blocked' },
188
- // The Na⁺/K⁺ pump (outside on the left): 3 Na⁺ out, 2 K⁺ in, per ATP (50 kJ/mol); vmax ≈ 3000
189
- // pumps per µm² at 100 cycles/s.
190
- reactions: [{ equation: '3 Na+(right) + 2 K+(left) + ATP = 3 Na+(left) + 2 K+(right) + ADP', fixed: { ATP: 50e3, ADP: 0 }, vmax: 5e-7, K: { 'Na+': 10, 'K+': 1.5 } }],
281
+ const oxide = { species: [], materials: { SiO2: { epsr: 3.9, species: {} } } };
282
+ const tox = units.nm(10), Cox = (3.9 * EPS0) / tox, NA = units.perCm3(1e17);
283
+ const mos = (Vg, zeroCharge) =>
284
+ build({
285
+ T: 300,
286
+ library: [semiconductor('Si'), metal('Al'), oxide],
287
+ stack: [
288
+ ohmic(Vg, ['e-']), // the gate's terminal: the aluminium's electrons
289
+ layer('Al', units.nm(20)),
290
+ { phi: { type: 'capacitive', C: 10 }, zeroCharge }, // the gate's face
291
+ layer('SiO2', tox),
292
+ { dipole: 0 }, // the oxide holds no species, so this alignment changes nothing
293
+ layer('Si', units.um(1), { name: 'Si', acceptors: NA }),
294
+ ohmic(0),
295
+ ],
296
+ ports: [{ name: 'channel', region: 'Si', from: 0, to: units.nm(1), V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' } }],
297
+ grid: { hmin: units.nm(0.25), hmax: units.nm(50), ratio: 1.15 },
298
+ });
299
+ // The gate's alignment from vacuum levels: Al's work function 4.1 eV, Si's electron affinity 4.05 eV.
300
+ const zc = vacuumZeroCharge(mos(0, 0), 4.1, { material: 'Si', anchor: 'e-', offset: 4.05 });
301
+ const dev = new Device(mos(0, zc));
302
+ const Q = (Vg) => {
303
+ dev.set({ contacts: { left: { V: Vg } } });
304
+ return dev.solve().interfaces[0].D;
191
305
  };
192
- const cell = build({
193
- T,
194
- library: [water],
195
- stack: [
196
- bath(out, 'Cl-'), // the extracellular fluid, held
197
- layer('water', 1e-6, { name: 'outside', c0: out }),
198
- membrane,
199
- layer('water', 5e-6, { name: 'cytoplasm', c0: inside }), // a closed cell: its contents set by leaks and the pump
200
- { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
201
- ],
202
- grid: { hmin: 50e-9, hmax: 200e-9 },
306
+ for (const Vg of [-2, -0.95, 0, 0.4, 2]) {
307
+ const C = (Q(Vg + 0.01) - Q(Vg - 0.01)) / 0.02;
308
+ 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
+ }
310
+ // 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
313
+ ```
314
+
315
+ `dev.impedance([f], { terminal: 'left' })` gives the same C at low frequency. Draw it with
316
+ `bandDiagram(sol, { energy: true, phi: ['SiO2'] })`: across an insulator φ is the line to draw.
317
+
318
+ ## Template: a Schottky diode
319
+
320
+ A barrier pinned at the metal (`phi: 'pinned'`, `zeroCharge` the barrier height for Si, whose
321
+ μ°_e⁻ is 0), crossed at the emission velocity: a `velocity` link, N_in = v (c_eq − c). With
322
+ `'equilibrium'` links (infinite velocity) it's diffusion theory instead, 20 times the current here.
323
+
324
+ ```js
325
+ import { Device, units, FARADAY, GAS_CONSTANT } from 'driftlet';
326
+ import { build, layer, ohmic, semiconductor } from 'driftlet/kit';
327
+
328
+ const T = 300, VT = (GAS_CONSTANT * T) / FARADAY, Si = semiconductor('Si');
329
+ const phiB = 0.85, A = 1.12e6; // the barrier (V); Richardson's constant for n-Si, A/(m²K²)
330
+ const v = (A * T * T) / (FARADAY * Si.materials.Si.species['e-'].cRef); // Bethe's emission velocity, 2.2e4 m/s (cRef is N_c)
331
+ const schottky = (V) => ({
332
+ V,
333
+ terminal: 'e-',
334
+ species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v, offset: 0 } },
335
+ phi: 'pinned',
336
+ zeroCharge: phiB, // E_c − E_F at the metal
203
337
  });
204
- const dev = new Device(cell), sol = dev.solve();
205
- const g = sol.x.length - 1, ci = (s) => sol.c[s][g], Vm = sol.phi[g] - sol.phi[0];
206
- // Mullins–Noda: in steady state the pump's 3:2 sets the ratio of the Na⁺ and K⁺ leaks.
207
- const mn = VT * Math.log((1.5 * P['K+'] * out['K+'] + P['Na+'] * out['Na+']) / (1.5 * P['K+'] * ci('K+') + P['Na+'] * ci('Na+')));
208
- console.log(`V_m = ${(Vm * 1e3).toFixed(2)} mV (Mullins–Noda ${(mn * 1e3).toFixed(2)}), inside Na⁺ ${ci('Na+').toFixed(1)} K⁺ ${ci('K+').toFixed(1)} mM`);
209
- console.log(check(dev, sol).text); // per compartment: Na⁺ leaks into the cytoplasm as fast as it's pumped out
338
+ const dev = new Device(build({ T, library: [Si], stack: [schottky(0), layer('Si', units.um(2), { donors: units.perCm3(1e16) }), ohmic(0)], grid: { hmin: units.nm(1), hmax: units.nm(20) } }));
339
+ for (const V of [0.1, 0.2, 0.3]) {
340
+ dev.set({ contacts: { left: { V } } }); // the metal positive: forward
341
+ const J = dev.solve().current, te = A * T * T * Math.exp(-phiB / VT) * Math.expm1(V / VT);
342
+ console.log(`${V} V: J ${J.toExponential(3)} A/m², thermionic emission ${te.toExponential(3)}`); // about 4% below: the depletion region in series
343
+ }
210
344
  ```
211
345
 
212
- Between two baths instead (the inside one `bath(inside, 'Cl-', { I: 0 })`: held composition, no
213
- current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium.
346
+ The same `velocity` link at an ohmic contact is its surface recombination velocity.
214
347
 
215
- ## Template: an action potential
348
+ ## Template: a thin-film transistor (a capacitance along a channel)
216
349
 
217
- Voltage-gated channels: a face's `gates` (each a fraction open with Hodgkin–Huxley kinetics,
218
- dx/dt = α(V)(1 − x) − β(V)x, in V = φ_right − φ_left), and a permeability scaled by them
219
- (`gates: { m: 3, h: 1 }`). `hodgkinHuxley()` in the kit writes the squid axon's: its gates, and
220
- GHK permeabilities matched to HH's conductances at rest. A pump-less cell has no steady rest
221
- (`solve()` finds Donnan), so start the transient from the concentrations given: the membrane
222
- settles within milliseconds.
350
+ A **port with a `capacitance`** holds charge σ = C (V − zeroCharge − φ) per area of electrode
351
+ along its window, balanced by the region's: a gate along a channel, a porous electrode's double
352
+ layer. Here x runs from source to drain with the gate spread along it: the gradual-channel
353
+ approximation, exact for a thin film until it pinches off.
223
354
 
224
355
  ```js
225
- import { Device } from 'driftlet';
226
- import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
356
+ import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
227
357
 
228
- const T = 279.45; // HH's 6.3 °C
229
- const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
230
- const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
231
- water.species.push({ name: 'A-', z: -1, cRef: 1000 });
232
- water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
233
- const hh = hodgkinHuxley({ inside, outside: out, T }); // outside on the left
234
- const axon = new Device(
235
- build({
358
+ const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
359
+ const t = 20e-9, L = 10e-6, W = 100e-6, C = 1e-3, mu = 1e-3; // film, length, width (m); F/m² of gate; m²/(V·s)
360
+ const tft = (Vg, Vd) =>
361
+ new Device({
236
362
  T,
237
- library: [water],
238
- stack: [
239
- bath(out, 'Cl-'),
240
- layer('water', 5e-6, { name: 'outside', c0: out }),
241
- { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
242
- layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
243
- { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
244
- ],
245
- // the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
246
- ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
247
- grid: { hmin: 50e-9, hmax: 1e-6 },
248
- }),
249
- );
250
- let peak = -1;
251
- for (let k = 1; k <= 300; k++) {
252
- const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
253
- peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
363
+ species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
364
+ materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
365
+ regions: [{ name: 'channel', material: 'film', length: L }],
366
+ contacts: {
367
+ left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
368
+ right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
369
+ },
370
+ ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
371
+ grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
372
+ });
373
+ // The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
374
+ for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
375
+ const sol = tft(2, Vd).solve();
376
+ const I = -sol.current * t * W; // sol.current is per m² of the film's section
377
+ console.log(`V_D ${Vd.toFixed(1)} V: I_D ${(I * 1e6).toFixed(2)} µA, σ at the drain ${(sol.ports[0].sigma.at(-1) * 1e3).toFixed(3)} mC/m²`);
254
378
  }
255
- console.log(`peak ${(peak * 1e3).toFixed(1)} mV`); // ~ +12 mV: GHK's Na⁺ current is limited by the 50 mM inside
256
379
  ```
257
380
 
258
- ## Template: an electrode in a stirred solution
381
+ To sweep the gate on one device instead, patch the port: `dev.set({ ports: { gate: { V } } })`.
382
+ An organic electrochemical transistor is the same with a volumetric capacitance C*:
383
+ `area: 1 / t, capacitance: { C: Cstar * t }`.
384
+
385
+ ## Template: an electrochemical cell
386
+
387
+ An electrode is a metal layer with the reaction at its face, behind a contact holding the
388
+ metal's electrons (`ohmic(V, ['e-'])`).
389
+
390
+ ```js
391
+ import { Device } from 'driftlet';
392
+ import { build, layer, ohmic, half, level, SHE, aqueous, metal } from 'driftlet/kit';
393
+
394
+ const silver = half('Ag+ + e- = Ag(s)', { 'Ag(s)': 0 }); // μ of the element: 0 on table conventions
395
+ // A Stern layer at each metal face: its capacitance and the potential of zero charge are
396
+ // properties of that interface, so they're yours to choose.
397
+ const face = { phi: { type: 'capacitive', C: 0.2 }, zeroCharge: 0.1, reactions: [{ ...silver, k0: 1e-3, alpha: 0.5 }] };
398
+ const def = build({
399
+ library: [aqueous(['H+', 'Ag+', 'NO3-']), metal('Ag')],
400
+ stack: [
401
+ ohmic(0, ['e-']),
402
+ layer('Ag', 1e-6),
403
+ face,
404
+ layer('water', 20e-6, { c0: { 'H+': 1, 'Ag+': 10, 'NO3-': 11 } }),
405
+ face,
406
+ layer('Ag', 1e-6),
407
+ ohmic(0.05, ['e-']),
408
+ ],
409
+ grid: { hmin: 0.1e-9, hmax: 200e-9, ratio: 1.15 },
410
+ });
411
+ const sol = new Device(def).solve();
412
+ const mid = sol.x.length >> 1;
413
+ console.log(`I = ${sol.current.toFixed(2)} A/m²`);
414
+ // The silver couple's redox level, and the SHE level, mid-cell (electron voltages).
415
+ console.log(level(sol, silver)[mid] - level(sol, SHE, { standard: true })[mid]); // ≈ 0.80 + (RT/F) ln(c/1 M)
416
+ ```
417
+
418
+ ## Template: an electrode in a stirred solution, and a potential step
259
419
 
260
- One working electrode, a stagnant layer of solution, and the well-stirred bulk as a bath. The
261
- bath's terminal is its reference species (Cl⁻ here, like a Ag/AgCl reference), so terminal
262
- voltages are against that. With table μ° (μ°_H⁺ = 0), V − φ in the solution reads against SHE.
263
- A terminal's current is into the device, so at the working electrode it's positive when
264
- oxidising (anodic). An excess of supporting electrolyte (KCl) suppresses migration but doesn't
265
- remove it: at 100:1 the limiting current is still about 1% off pure diffusion.
420
+ A working electrode, a stagnant layer, and the stirred bulk as a bath whose terminal is its
421
+ 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.
266
426
 
267
427
  ```js
268
428
  import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
269
- import { build, layer, ohmic, bath, half, aqueous, metal } from 'driftlet/kit';
429
+ import { build, layer, ohmic, bath, half, aqueous, metal, IONS } from 'driftlet/kit';
270
430
 
271
431
  const iron = half('Fe3+ + e- = Fe2+');
272
432
  const electrode = (drive) =>
@@ -275,7 +435,7 @@ const electrode = (drive) =>
275
435
  stack: [
276
436
  ohmic(drive, ['e-']), // the working electrode's terminal: the platinum's electrons
277
437
  layer('Pt', 1e-6),
278
- { reactions: [{ ...iron, k0: 1e-3 }] },
438
+ { reactions: [{ ...iron, k0: 1e-3 }] }, // i₀ = F·k0 ≈ 96 A/m² at 1 M each; about 1 A/m² at these mM
279
439
  layer('water', 20e-6), // the stagnant layer
280
440
  bath({ 'K+': 500, 'Cl-': 535, 'Fe3+': 5, 'Fe2+': 10 }, 'Cl-'), // the stirred bulk (neutral)
281
441
  ],
@@ -283,27 +443,42 @@ const electrode = (drive) =>
283
443
  });
284
444
  // At rest (no current), the electrode sits at the couple's Nernst potential.
285
445
  const rest = new Device(electrode({ I: 0 })).solve();
286
- const E = rest.V['e-'][0] - rest.phi[rest.x.length - 1]; // against SHE
287
- const VT = (GAS_CONSTANT * 298.15) / FARADAY;
288
- console.log(`rest potential ${E.toFixed(4)} V vs SHE; E° + V_T ln(c₃/c₂) = ${(0.769 + VT * Math.log(5 / 10)).toFixed(4)} V`);
289
- // Polarised 0.2 V positive of rest: oxidising Fe²⁺, toward its limiting current F·D·c/L.
290
- const dev = new Device(electrode(rest.terminals.left.V + 0.2));
291
- console.log(`anodic current ${dev.solve().terminals.left.current.toFixed(2)} A/m²`);
446
+ const vsSHE = (sol) => sol.V['e-'][0] - sol.phi.at(-1);
447
+ const VT = (GAS_CONSTANT * 298.15) / FARADAY, Erest = vsSHE(rest);
448
+ console.log(`rest ${Erest.toFixed(4)} V vs SHE; E° + V_T ln(c₃/c₂) = ${(0.769 + VT * Math.log(5 / 10)).toFixed(4)} V`);
449
+ const drive = (E) => E - Erest + rest.terminals.left.V; // the terminal voltage for E vs SHE
450
+ // Steady, 0.2 V positive of rest: oxidising Fe²⁺. Its limiting current F·D·c/L is 35 A/m²; the
451
+ // kinetics (i₀ ≈ 1 A/m²) still hold it to about half here.
452
+ console.log(`anodic ${new Device(electrode(drive(Erest + 0.2))).solve().terminals.left.current.toFixed(2)} A/m²`); // 16.81
453
+ // A potential step from rest to 0.5 V positive: at first Fe²⁺ is depleted as at a plane,
454
+ // Cottrell's F·c·√(D/πt), until the stagnant layer is crossed (L²/D ≈ 0.5 s).
455
+ const dev = new Device(electrode(drive(Erest)));
456
+ dev.solve();
457
+ dev.set({ contacts: { left: { V: drive(Erest + 0.5) } } });
458
+ const run = dev.advance(0.03, { dtMax: 1e-3 }); // run.trace: this call's accepted steps
459
+ for (const t of [3e-3, 1e-2, 3e-2]) {
460
+ const k = run.trace.t.findIndex((tk) => tk >= t);
461
+ // (trace.current is the right contact's toward +x: positive for an anodic electrode on the left)
462
+ console.log(`${t * 1e3} ms: ${run.trace.current[k].toFixed(2)} A/m², Cottrell ${(FARADAY * 10 * Math.sqrt(IONS['Fe2+'].D / (Math.PI * run.trace.t[k]))).toFixed(2)}`);
463
+ }
292
464
  ```
293
465
 
466
+ A cyclic voltammogram is this electrode driven by `triangle({ from, to, period })`, the period
467
+ being 2|to − from| over the scan rate: `solve()` it at `from` first, since the scan starts there
468
+ at t = 0, then `advance()`. Its potential against the bath's terminal is −`trace.voltage` (that
469
+ is V_right − V_left), and `trace.current` is anodic positive.
470
+
294
471
  ## Template: corrosion under a drop (an electrode spread through a film)
295
472
 
296
- A **port with reactions** is an electrode spread through a window of a region: a metal under a
297
- thin film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
298
- `area` the electrode's area per volume of the window (1/h under a film h thick), and each
299
- reaction is Butler–Volmer per area, as at a face. The region must be strictly neutral (ε = 0); a
300
- `capacitance` adds the double layer. A **`geometry`** gives the device a cross-section A(x): here
301
- x is the radius of a drop seen from its centre, A = 2πr·h(r), and every current and amount becomes
302
- a total through it (A, mol), not per m².
473
+ A **port with reactions** is an electrode spread through a strictly neutral region: a metal under
474
+ a film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
475
+ `area` its area per volume (1/h under a film h thick), each reaction Butler–Volmer per area, and
476
+ a `capacitance` the double layer. A **`geometry`** gives the device a cross-section A(x): here x
477
+ is the radius of a drop, A = 2πr·h(r), and currents and amounts become totals (A, mol).
303
478
 
304
479
  ```js
305
480
  import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
306
- import { aqueous, polarization } from 'driftlet/kit';
481
+ import { aqueous, polarization, H2O } from 'driftlet/kit';
307
482
 
308
483
  // Salt water on iron: a drop 3 mm across. The iron dissolves and reduces O₂, which comes in from
309
484
  // the air through the drop's top, fastest at the thin rim: the centre corrodes, the rim doesn't.
@@ -321,7 +496,7 @@ const dev = new Device({
321
496
  regions: [{ name: 'drop', material: 'water', length: R, c0: { 'Na+': 500, 'Cl-': 500 + 2e-6, 'Fe2+': 1e-6, 'OH-': 1e-4, 'H+': 1e-4, O2: cO2 } }],
322
497
  contacts: { left: { phi: 'neutral' }, right: { phi: 'neutral' } }, // closed: the centre, and the rim
323
498
  bulkReactions: [
324
- { equation: 'H+ + OH- = H2O', fixed: { H2O: -237.129e3 }, kf: { water: 1.4e8 } },
499
+ { equation: 'H+ + OH- = H2O', fixed: { H2O }, kf: { water: 1.4e8 } }, // H2O: water's μ, −237.129 kJ/mol
325
500
  // O₂ from the air through the drop: a rate k_f (1 − c/c_sat), k_f = D c_sat/h² (not a port's
326
501
  // exchange link, whose rate is linear in μ rather than c)
327
502
  { equation: 'Air = O2', fixed: { Air: 16.4e3 + RT * Math.log(cO2 / 1000) }, kf: { water: { x: r, values: h.map((y) => (D * cO2) / (y * y)) } } },
@@ -333,7 +508,7 @@ const dev = new Device({
333
508
  capacitance: { C: 0.2, zeroCharge: -0.4 }, // the double layer: the potential moves continuously
334
509
  reactions: [
335
510
  { equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
336
- { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O: -237.129e3 }, k0: 3e-9, alpha: 0.125 },
511
+ { equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O }, k0: 3e-9, alpha: 0.125 },
337
512
  ],
338
513
  }],
339
514
  grid: { hmin: 5e-6, hmax: 60e-6, ratio: 1.2 },
@@ -350,55 +525,195 @@ const evans = polarization(dev, sol, { port: 'iron', x: 0 }, levels);
350
525
  for (const rx of evans.reactions) console.log(`${rx.equation}: ${Array.from(rx.current, (i) => i.toFixed(2)).join(', ')} A/m²`);
351
526
  ```
352
527
 
353
- On an electrode's surface, species can take its sites (`surface: { name: { mu0, capacity } }`,
354
- Langmuir coverages), and a reaction with `bare: true` runs only on bare metal: a passive film
355
- (docs/device.md, "An electrode's surface").
528
+ Species can take an electrode's surface sites (`surface`, Langmuir coverages), and a reaction
529
+ with `bare: true` runs only on bare metal: a passive film (docs/device.md). A battery's insertion
530
+ electrode is a material described by its OCV curve (docs/statistics.md).
356
531
 
357
- ## Template: a thin-film transistor (a capacitance along a channel)
532
+ ## Template: a cell membrane
358
533
 
359
- A **port with a `capacitance`** holds charge σ = C (V − zeroCharge − φ) per area of electrode
360
- along its window, balanced by the region (Gauss's law, or neutrality at ε = 0): a gate along a
361
- channel, a porous electrode's double layer. In 1D a field-effect transistor is its channel, x from
362
- source to drain, with the gate spread along it: the gradual-channel approximation, exact for a thin
363
- film until it pinches off.
534
+ A lipid bilayer is a face: a capacitor (1 µF/cm², 0.01 F/m²) crossed by channels with a
535
+ permeability (`{ type: 'permeability', P }`, m/s: Goldman–Hodgkin–Katz comes out of it) and by
536
+ pumps, reactions with saturating kinetics (`vmax`, `K`). The membrane potential is φ_in − φ_out,
537
+ read from `sol.phi` as below. (A bath's terminal V is its φ only without a reference species,
538
+ `bath(c, drive)`; `bath(c, 'Cl-')`'s is Cl⁻'s level, which differs by Cl⁻'s Nernst term.)
364
539
 
365
540
  ```js
366
- import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
541
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
542
+ import { build, layer, bath, aqueous, check } from 'driftlet/kit';
367
543
 
368
- const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
369
- const t = 20e-9, L = 10e-6, W = 100e-6, C = 1e-3, mu = 1e-3; // film, length, width (m); F/m² of gate; m²/(V·s)
370
- const tft = (Vg, Vd) =>
371
- new Device({
544
+ const T = 310.15, VT = (GAS_CONSTANT * T) / FARADAY;
545
+ const out = { 'Na+': 145, 'K+': 5, 'Cl-': 110, 'A-': 40 }; // mM = mol/m³; A⁻: anions that don't cross
546
+ const inside = { 'Na+': 12, 'K+': 140, 'Cl-': 10, 'A-': 142 };
547
+ const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 }); // strictly neutral solutions
548
+ water.species.push({ name: 'A-', z: -1, cRef: 1000 });
549
+ water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
550
+ const P = { 'K+': 1e-8, 'Na+': 0.04e-8, 'Cl-': 0.45e-8 }; // m/s, Hodgkin and Katz's ratios
551
+ const membrane = {
552
+ phi: { type: 'capacitive', C: 0.01 },
553
+ species: { 'K+': { type: 'permeability', P: P['K+'] }, 'Na+': { type: 'permeability', P: P['Na+'] }, 'Cl-': { type: 'permeability', P: P['Cl-'] }, 'A-': 'blocked' },
554
+ // The Na⁺/K⁺ pump (outside on the left): 3 Na⁺ out, 2 K⁺ in, per ATP (50 kJ/mol); vmax ≈ 3000
555
+ // pumps per µm² at 100 cycles/s.
556
+ reactions: [{ equation: '3 Na+(right) + 2 K+(left) + ATP = 3 Na+(left) + 2 K+(right) + ADP', fixed: { ATP: 50e3, ADP: 0 }, vmax: 5e-7, K: { 'Na+': 10, 'K+': 1.5 } }],
557
+ };
558
+ const cell = build({
559
+ T,
560
+ library: [water],
561
+ stack: [
562
+ bath(out, 'Cl-'), // the extracellular fluid, held
563
+ layer('water', 1e-6, { name: 'outside', c0: out }),
564
+ membrane,
565
+ layer('water', 5e-6, { name: 'cytoplasm', c0: inside }), // a closed cell: its contents set by leaks and the pump
566
+ { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
567
+ ],
568
+ grid: { hmin: 50e-9, hmax: 200e-9 },
569
+ });
570
+ const dev = new Device(cell), sol = dev.solve();
571
+ const g = sol.x.length - 1, ci = (s) => sol.c[s][g], Vm = sol.phi[g] - sol.phi[0];
572
+ // Mullins–Noda: in steady state the pump's 3:2 sets the ratio of the Na⁺ and K⁺ leaks.
573
+ const mn = VT * Math.log((1.5 * P['K+'] * out['K+'] + P['Na+'] * out['Na+']) / (1.5 * P['K+'] * ci('K+') + P['Na+'] * ci('Na+')));
574
+ console.log(`V_m = ${(Vm * 1e3).toFixed(2)} mV (Mullins–Noda ${(mn * 1e3).toFixed(2)}), inside Na⁺ ${ci('Na+').toFixed(1)} K⁺ ${ci('K+').toFixed(1)} mM`);
575
+ console.log(check(dev, sol).text); // per compartment: Na⁺ leaks into the cytoplasm as fast as it's pumped out
576
+ ```
577
+
578
+ Between two baths instead (the inside one `bath(inside, 'Cl-', { I: 0 })`: held composition, no
579
+ current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium.
580
+
581
+ ## Template: a voltage clamp
582
+
583
+ A face's `gates` (each a fraction open, dx/dt = α(V)(1 − x) − β(V)x with V = φ_right − φ_left)
584
+ scale its links (`gates: { m: 3, h: 1 }`). `hodgkinHuxley({ T, linear: true })` (kit) writes the
585
+ squid axon's as Hodgkin and Huxley had them, conductances g(V − E). Between two baths without a
586
+ reference species, V_right − V_left is V_m, so a held step is their voltage clamp.
587
+
588
+ ```js
589
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
590
+ import { build, layer, bath, aqueous, hodgkinHuxley } from 'driftlet/kit';
591
+
592
+ const T = 279.45, VT = (GAS_CONSTANT * T) / FARADAY; // HH's 6.3 °C
593
+ const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
594
+ const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
595
+ water.species.push({ name: 'A-', z: -1, cRef: 1000 });
596
+ water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
597
+ const hh = hodgkinHuxley({ T, linear: true });
598
+ const step = -0.01; // from −65 mV to −10 mV at 1 ms
599
+ const dev = new Device(
600
+ build({
372
601
  T,
373
- species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
374
- materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
375
- regions: [{ name: 'channel', material: 'film', length: L }],
376
- contacts: {
377
- left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
378
- right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
379
- },
380
- ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
381
- grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
382
- });
383
- // The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
384
- for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
385
- const sol = tft(2, Vd).solve();
386
- const I = -sol.current * t * W; // sol.current is per m² of the film's section
387
- console.log(`V_D ${Vd.toFixed(1)} V: I_D ${(I * 1e6).toFixed(2)} µA, σ at the drain ${(sol.ports[0].sigma.at(-1) * 1e3).toFixed(3)} mC/m²`);
602
+ library: [water],
603
+ stack: [
604
+ bath(out), // the outside, on the left
605
+ layer('water', 1e-6, { c0: out }),
606
+ { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
607
+ layer('water', 1e-6, { c0: inside }),
608
+ bath(inside, { t: [0, 1e-3, 1e-3, 1], values: [-0.065, -0.065, step, step] }),
609
+ ],
610
+ grid: { hmin: 20e-9, hmax: 0.2e-6 },
611
+ }),
612
+ );
613
+ dev.solve(); // the rest at −65 mV, the gates at their values there
614
+ const run = dev.advance(11e-3, { dtMax: 0.02e-3, probes: [{ interface: 0, species: 'Na+' }, { interface: 0, species: 'K+' }] });
615
+ const [na, k] = run.trace.probes.map((N) => N.map((x) => -FARADAY * x)); // outward currents, A/m²
616
+ const peak = na.indexOf(Math.min(...na)), EK = VT * Math.log(out['K+'] / inside['K+']);
617
+ console.log(`I_Na peaks ${((run.trace.t[peak] - 1e-3) * 1e3).toFixed(2)} ms into the step, ${(na[peak] * 0.1).toFixed(2)} mA/cm²`); // 0.73 ms, −1.48
618
+ console.log(`g_K after 10 ms: ${((k.at(-1) / (step - EK)) * 0.1).toFixed(2)} mS/cm²`); // 21.12: HH's 36 n∞⁴ is 21.5, which n nearly reaches by 10 ms
619
+ ```
620
+
621
+ ## Template: an action potential
622
+
623
+ Without `linear: true` (given `inside` and `outside`), `hodgkinHuxley` writes GHK permeabilities
624
+ matched to HH's conductances at −65 mV, which rectify as real channels do. A pump-less cell has no
625
+ steady rest (`solve()` finds Donnan), so start the transient from the concentrations given.
626
+
627
+ ```js
628
+ import { Device } from 'driftlet';
629
+ import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
630
+
631
+ const T = 279.45; // HH's 6.3 °C
632
+ const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
633
+ const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
634
+ water.species.push({ name: 'A-', z: -1, cRef: 1000 });
635
+ water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
636
+ const hh = hodgkinHuxley({ inside, outside: out, T }); // GHK channels; outside on the left
637
+ const axon = new Device(
638
+ build({
639
+ T,
640
+ library: [water],
641
+ stack: [
642
+ bath(out, 'Cl-'),
643
+ layer('water', 5e-6, { name: 'outside', c0: out }),
644
+ { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
645
+ layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
646
+ { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
647
+ ],
648
+ // the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
649
+ ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
650
+ grid: { hmin: 50e-9, hmax: 1e-6 },
651
+ }),
652
+ );
653
+ let peak = -1;
654
+ for (let k = 1; k <= 300; k++) {
655
+ const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
656
+ peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
388
657
  }
658
+ console.log(`peak ${(peak * 1e3).toFixed(1)} mV`); // +12.6: GHK's Na⁺ current is limited by the 50 mM inside (linear: true gives +37.9, HH's own)
659
+ ```
660
+
661
+ ## Template: an action potential travelling along an axon
662
+
663
+ Here x runs along the axon: the axoplasm is one region, and the membrane a port spanning it, a
664
+ capacitance and gated channels to an outside given as a `bath` (the port's V is then the
665
+ outside's φ). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
666
+ per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
667
+ `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).
669
+
670
+ ```js
671
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
672
+ import { hodgkinHuxley, injector, pulse } from 'driftlet/kit';
673
+
674
+ const T = 291.65, VT = (GAS_CONSTANT * T) / FARADAY, a = 238e-6; // 18.5 °C, a squid giant axon
675
+ const inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52.5, 'A-': 397.5 }; // mM
676
+ // Outside levels that put the Nernst potentials at HH's +50, −77 and −54.4 mV (leak as Cl⁻).
677
+ const outside = { 'Na+': 50 * Math.exp(0.05 / VT), 'K+': 400 * Math.exp(-0.077 / VT), 'Cl-': 52.5 * Math.exp(0.054387 / VT) };
678
+ // One D for the small ions, giving HH's 35.4 Ω·cm.
679
+ const D = 1 / 0.354 / ((FARADAY * FARADAY) / (GAS_CONSTANT * T)) / (50 + 400 + 52.5);
680
+ const sp = (d) => ({ D: d, mu0: 0, cRef: 1000 });
681
+ const sealed = { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' };
682
+ const hh = hodgkinHuxley({ T, area: 2 / a });
683
+ const axon = new Device({
684
+ T,
685
+ species: [{ name: 'Na+', z: 1 }, { name: 'K+', z: 1 }, { name: 'Cl-', z: -1 }, { name: 'A-', z: -1 }],
686
+ materials: { axoplasm: { epsr: 0, species: { 'Na+': sp(D), 'K+': sp(D), 'Cl-': sp(D), 'A-': sp(1e-12) } } },
687
+ regions: [{ name: 'axon', material: 'axoplasm', length: 0.06, c0: inside }],
688
+ contacts: { left: sealed, right: sealed },
689
+ ports: [
690
+ { name: 'membrane', region: 'axon', V: 0, area: 2 / a, capacitance: { C: 0.01, zeroCharge: 0 }, bath: { c: outside }, gates: hh.gates, species: hh.species },
691
+ injector({ name: 'stim', region: 'axon', from: 0, to: 1e-3, species: 'K+', I: pulse({ start: 30e-3, width: 0.2e-3, amplitude: 30 }) }),
692
+ ],
693
+ grid: { hmin: 2e-4, hmax: 2e-4 },
694
+ });
695
+ axon.advance(30e-3); // settle to rest (no pump: start from the concentrations, not solve())
696
+ const sol = axon.advance(34e-3, { dtMax: 5e-6, probes: [{ x: 0.02, quantity: 'phi' }, { x: 0.04, quantity: 'phi' }] });
697
+ const { t, probes } = sol.trace, at = (v) => t[v.findIndex((x) => x > 0)]; // (φ inside, the outside's held at 0)
698
+ console.log(`${(0.02 / (at(probes[1]) - at(probes[0]))).toFixed(1)} m/s`); // 18.8 here (finely resolved, 18.73; HH computed 18.8)
389
699
  ```
390
700
 
391
- To sweep the gate on one device instead, patch the port by name: `dev.set({ ports: { gate: { V } } })`
392
- (a drive only, so it's cheap and keeps the state as a warm start). An organic electrochemical
393
- transistor is the same with a volumetric capacitance C*: `area: 1 / t, capacitance: { C: Cstar * t }`
394
- (docs/device.md, "A capacitance through a window").
701
+ ## Transients
395
702
 
396
- ## Template: a transient, with a pulse injected sideways
703
+ `advance(t, opts)` steps adaptively from the device's present state (its `c0`, or the steady
704
+ state if you called `solve()`) to time t. It returns the solution there, with `done` and a `trace`
705
+ of this call's accepted steps (`t`, `current`, `voltage`: V_right − V_left). It stops short at
706
+ `budgetMs` (an animation frame: call again) or, with a warning saying why, at `maxSteps` (100000)
707
+ or a step that won't converge. `probes`, read at accepted steps (give `dtMax` to resolve them):
708
+ `{ x, species }` (c, or `quantity: 'V'`), `{ x, quantity: 'phi' }`, and at a face `{ interface: f,
709
+ species }` (its flux) or `{ interface: f, gate }`.
397
710
 
398
- A **port** feeds a window of nodes inside a region from the side (a point contact, an injector,
399
- a MOS channel's source and drain), held at a voltage or driven by a current; `injector()` writes
400
- one that spreads a driven current evenly. Transients run with `advance(t)`, adaptively, from the
401
- steady state that `solve()` finds at time 0, and `probes` read the device at points inside it.
711
+ - **Events.** To stop at one (a cut-off voltage, a diode's storage time), advance in short calls
712
+ 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).
715
+ - **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
716
+ { terminal })` the small-signal Z(f) about the steady state, in Ω·m².
402
717
 
403
718
  ```js
404
719
  import { Device, units, FARADAY } from 'driftlet';
@@ -418,191 +733,107 @@ const def = build({
418
733
  });
419
734
  const dev = new Device(def);
420
735
  const rest = dev.solve(); // the steady state at t = 0 (solve() doesn't advance time)
421
- // In a page, call advance(tNext, { budgetMs: 12, probes }) once per animation frame, carrying on
422
- // next frame while sol.done is false (or let recorder() join the traces and keep frames to scrub
423
- // back through). Probes read at accepted steps; dtMax keeps them close.
424
736
  const run = dev.advance(10e-6, { dtMax: 20e-9, probes: [{ x: 1.525e-3, species: 'h+' }] });
425
737
  const seen = run.trace.probes[0], k = seen.indexOf(Math.max(...seen));
426
738
  const tArrive = run.trace.t[k] - width / 2; // from the pulse's middle
427
739
  console.log(`peak at ${(run.trace.t[k] * 1e6).toFixed(2)} µs, Δp ${units.toPerCm3(seen[k] - rest.c['h+'][0]).toExponential(1)} cm⁻³: μ_p ≈ ${(1e-3 / (E * tArrive) * 1e4).toFixed(0)} cm²/(V·s)`); // ≈ 1950 for the 1900 put in: a spreading, decaying pulse peaks a little early
428
740
  ```
429
741
 
430
- To start from a state instead of simulating how it got there (a packet already injected, a
431
- salt gradient laid down), give a region's `c0` a profile against the device's x:
432
- `c0: { 'h+': { x: [...], values: [...] } }`, piecewise linear. A given `c0` is the starting state,
433
- even for a species a contact feeds; species without one start at their contact's level (the
434
- left's if they reach it, else the right's), with φ made neutral node by node (electrons follow
435
- the holes). Between two different solutions, give every region its `c0`. Then `advance()` without `solve()`, which would go straight to the steady
436
- state.
742
+ To start from a state rather than simulate how it got there (a packet already injected, a salt
743
+ gradient laid down), give a region's `c0` a profile, `{ 'h+': { x: [...], values: [...] } }` against
744
+ the device's x, and `advance()` the new `Device` without `solve()`. A given `c0` is the starting
745
+ state even for a species a contact feeds; the rest start at their contact's level. The grid isn't
746
+ fitted to the profile: grade it fine where the profile is steep (a warning says where it isn't).
747
+ `advance()`'s `tol` (1e-3) is each step's error, in thermal units: tighten it, or cap `dtMax`, for
748
+ numbers to compare with theory.
437
749
 
438
- Transients cost more where a carrier's density spans many decades (minority holes in silicon
439
- ahead of the pulse) and at strong injection (Δp near n₀); budget for that, or coarsen the grid.
440
-
441
- `recorder(dev, { every, times, probes })` (kit) runs a transient in pieces: its `advance(tEnd,
442
- opts)` takes the same options, `rec.trace` is the whole run's trace from the start, and
443
- `rec.frames` are whole solutions (at the start, then at `times`, every `every` s, or the end of
444
- each call), with `rec.frame(t)` the one at or before t, for a time slider.
445
-
446
- **Prefer a curated device to inventing numbers** where one fits: `perovskiteCell({ V, light,
447
- bulkSRH })` is IonMonger's default perovskite solar cell (mobile iodide vacancies, SRH in the bulk
448
- and at both faces), and `PEROVSKITE_SCANS` holds IonMonger's J–V scans of it at seven rates;
449
- `hysteresis(rec.trace, turn)` reads the same figures from yours, so a demo can show its agreement.
450
- The definition is plain data: print it, edit what your demo varies.
451
-
452
- ## Template: a live page with a slider
453
-
454
- `live()` merges slider moves made during a solve, warm-starts each solve and keeps the last
455
- good solution. `bandDiagram()` returns an SVG string. Pass `worker: true` to `live()` to solve in
456
- a Web Worker (it works with the library loaded from a CDN: a same-origin stub imports it). Calling
457
- `set()` from `onsolution` is fine, for a quantity the page solves for itself (it queues the next
458
- solve), as long as it settles rather than ping-pongs.
459
-
460
- ```html
461
- <!doctype html>
462
- <meta charset="utf-8">
463
- <title>pn junction</title>
464
- <label>bias <input id="V" type="range" min="-0.5" max="0.7" step="0.01" value="0"></label>
465
- <span id="readout"></span>
466
- <div id="figure"></div>
467
- <script type="module">
468
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/index.js';
469
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/kit.js';
470
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/plot.js';
471
-
472
- const def = build({
473
- T: 300,
474
- library: [semiconductor('Si')],
475
- stack: [ohmic(0), layer('Si', 1e-6, { donors: units.perCm3(1e17) }), layer('Si', 1e-6, { acceptors: units.perCm3(1e16) }), ohmic(0)],
476
- bulkReactions: [{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }],
477
- grid: { hmin: 1e-9, hmax: 20e-9 },
478
- });
479
- const slider = document.getElementById('V');
480
- const dev = live(def, {
481
- onsolution(sol, info) {
482
- document.getElementById('figure').innerHTML = bandDiagram(sol, { energy: true, title: `V = ${sol.terminals.right.V.toFixed(2)} V` });
483
- document.getElementById('readout').textContent = info.failed ? 'no solution here' : `I = ${sol.current.toExponential(2)} A/m²`;
484
- },
485
- });
486
- slider.addEventListener('input', () => dev.set({ contacts: { right: { V: +slider.value } } }));
487
- </script>
488
- ```
750
+ Transients cost more where a carrier's density spans many decades (minority holes in silicon)
751
+ and at strong injection; budget for that, or coarsen the grid. `recorder(dev, { every, times,
752
+ probes })` (kit) runs one in pieces: `rec.trace` is the whole run's, and `rec.frame(t)` the
753
+ whole solution at or before t, for a time slider.
489
754
 
490
755
  ## Reading level diagrams
491
756
 
492
- What `bandDiagram()` and `traces()` draw, and how to explain it to a reader (docs/visualization.md
493
- has the long form):
494
-
495
- - **Plotting in your own style.** `bandDiagram()` is a quick look. For a page of your own, draw
496
- with your own charting code from `traces(sol, opts)` (in `driftlet/kit`, the same options):
497
- `{ x, series: [{ id, label, kind, role, slot, y }], regions, faces, range }`, where `kind` says how
498
- a line reads (`'level'` thick, `'standard'` thin, `'redox'`, `'phi'`), `role` and `slot` keep a
499
- species' colour stable (`themeColor(role, slot)` in `driftlet/plot` gives ours), labels mark
500
- subscripts TeX-style (`labelParts(label)` splits them), `NaN` breaks a line, and doubled
501
- interface nodes share an x (draw them as vertical steps). Or read the solution directly:
502
- `sol.x`, `sol.V[name]`, `sol.Vstd[name]`, `sol.c[name]`, `sol.phi`; electron energies are
503
- E = −V (eV) for both carriers' levels and band edges.
504
-
505
- - **Which picture.** For a device of electrons and holes only, draw the familiar band diagram,
506
- energy up: `bandDiagram(sol, { energy: true })` (E_c, E_v, E_Fn, E_Fp in eV). The species
507
- voltages below are the same lines in volts, turned over; use them where ions share the diagram,
508
- or for equilibrium between species, and don't present them in place of the band diagram.
509
-
510
- - **Fermi level** V_e⁻ = −μ̄_e⁻/F: the circuit voltage, what a voltmeter reads. A cell's voltage is
511
- the difference between its terminals' V_e⁻.
512
- - **Redox level** of Ox + z e⁻ ⇌ Red, `level(sol, half)`: V_e⁻(Ox/Red) = (μ̄_Ox − μ̄_Red)/(zF), where
513
- an electrode exchanging electrons by that couple would sit in equilibrium with the local
514
- composition. Drawn thick and dashed. At an electrode face, the step from the metal's Fermi level
515
- to it is the surface overpotential; through the solution it slopes with ohmic drop and
516
- concentration polarisation. Each couple has its own; they agree only in redox equilibrium.
517
- - **Standard redox level** (`{ standard: true }`), thin and dashed: every participant at reference
518
- concentration. The redox level sits RT/(zF) ln(a_Ox/a_Red) from it (Nernst, locally).
519
- - **Electrode potentials** are gaps to the SHE's standard level V°_e⁻(SHE): E = V_e⁻(electrode) −
520
- V°_e⁻(SHE), E° = V°_e⁻(Ox/Red) − V°_e⁻(SHE), η = E − E_eq. With the data library's μ°,
521
- V°_e⁻(SHE) in water is φ, so `phi: true` draws it. It slopes under current: "vs SHE" needs a
522
- place (say, the stirred bulk).
523
- - **Species voltage** V_i = μ̄_i/(z_iF) (thick, solid) for every charged species: the Fermi level
524
- for e⁻, a quasi-Fermi level for e⁻ and h⁺ in a semiconductor, what a reversible electrode reads
525
- for an ion. It's a display of μ̄_i (the physics, which also covers neutral species): each
526
- species moves down its own μ̄_i, drift and diffusion together, so its current flows down slopes
527
- in V_i (cations and holes move downhill, electrons and anions uphill), and it's in equilibrium
528
- where V_i is flat. A step at a face means resistance there.
757
+ What `bandDiagram()` and `traces()` draw, and how to explain it (docs/visualization.md has more):
758
+
759
+ - **Which picture.** For electrons and holes only, draw the familiar band diagram, energy up:
760
+ `bandDiagram(sol, { energy: true })` (E_c, E_v, E_Fn, E_Fp in eV, E = −V). The species voltages
761
+ below are the same lines in volts, turned over: use them where ions share the diagram, or for
762
+ equilibrium between species, never in place of the band diagram.
763
+ - **Species voltage** V_i = μ̄_i/(z_iF) (thick, solid): the Fermi level for e⁻ (what a voltmeter
764
+ reads), a quasi-Fermi level in a semiconductor, what a reversible electrode reads for an ion.
765
+ Each species moves down its own μ̄_i, drift and diffusion together, so its current flows down
766
+ slopes in V_i (cations and holes downhill, electrons and anions uphill); flat is equilibrium,
767
+ and a step at a face is resistance there. Call it the species voltage and say what it is, since
768
+ it's new to most readers; credit Jamnik and Maier (Phys. Chem. Chem. Phys. 3, 1668 (2001))
769
+ where V_i rails explain impedance or linear response.
529
770
  - **Standard level** V°_i = φ + μ°_i/(z_iF) (thin, solid): a band edge for e⁻ and h⁺; V_i − V°_i =
530
- (RT/z_iF) ln(c/c_ref) for ideal species. Steps in V°_i at faces (Donnan, junctions, dipoles) are
531
- bookkeeping. In volts the conduction band sits below the valence band.
532
- - **What's left out.** φ isn't drawn: it's a per-material convention (meaningless in a metal), and
533
- −eφ isn't any electron's energy; the V°_i ladder shows its variation within a material and means
534
- something across faces too. The exceptions: across an insulator with no mobile species, a
535
- plain sloped φ (`phi: true`) is the line to draw (its slope is the field), and in vacuum −eφ
536
- really is an electron's energy. Nor is anything drawn against a reference: the diagrams are
537
- reference-free (shifting everything changes nothing), every gap on the electron diagram is
538
- physical, so a reader picks a level at a point as their reference. "vs SHE" and "from the vacuum"
539
- need a place, since both levels vary in space.
540
- - A gap V_i − V_j between two species is free of φ, but carries one constant fixed by the elements'
541
- conventional zeros (μ = 0 per element). So `shifts` (marked ⌇) moves a species' lines to share a
542
- plot without losing anything.
543
- - Call V_i the species voltage and say what it is; it's new to most readers. Credit Jamnik and
544
- Maier (Phys. Chem. Chem. Phys. 3, 1668 (2001)) where V_i rails explain impedance or linear
545
- response.
771
+ (RT/z_iF) ln(c/c_ref) for ideal species. Its steps at faces are bookkeeping.
772
+ - **Redox level** of Ox + z e⁻ ⇌ Red (`level(sol, half)`, or `levels: [{ half }]` in the plot
773
+ options; thick, dashed): (μ̄_Ox − μ̄_Red)/(zF), where an electrode by that couple would sit in
774
+ equilibrium with the local solution. At an electrode its step from the metal's Fermi level is
775
+ the surface overpotential. `standard: true` gives its standard level (thin, dashed).
776
+ - **Electrode potentials** are gaps to the SHE's standard level, which with the library's μ° is φ
777
+ in water (`phi: true` draws it): E = V_e⁻(electrode) − V°_e⁻(SHE).
778
+ - **What's left out.** φ is a per-material convention, and −eφ isn't any electron's energy, so it
779
+ isn't drawn, except across an insulator with no mobile species (`phi: ['SiO2']`: its slope is
780
+ the field). No level is a fixed reference: shifting every line together changes nothing. A gap
781
+ between two species' levels carries the elements' conventional zeros, so read it through a
782
+ reaction; `shifts` (marked ⌇) moves one species' lines to fit a shared axis.
783
+ - **Your own style.** `traces(sol, opts)` gives `{ x, series: [{ id, label, kind, role, slot, y }],
784
+ regions, faces, range }`: `kind` says how a line reads (`'level'`, `'standard'`, `'redox'`,
785
+ `'redox-standard'`, `'phi'`), `themeColor(role, slot)` (plot) gives our colours,
786
+ `labelParts(label)` splits TeX-style subscripts, `NaN` breaks a line, and a face's doubled nodes
787
+ share an x (a vertical step).
546
788
 
547
789
  ## Good habits
548
790
 
549
- - **Draw the closed-form answer alongside the simulation**, wherever a textbook limit exists
550
- (Shockley's law, a Nernst potential, a Gaussian pulse, a Warburg arc), as the demos and tests
551
- do. It checks the setup: a solver's output looks authoritative even when an input is wrong, and
552
- a mismatch is the cheapest way to catch it. Where the two should differ (high injection, a
553
- finite pulse), say why.
554
- - **Stay honestly inside 1D.** driftlet models devices along one coordinate: planar, or with a
555
- cross-section A(x) (`geometry`: spherical or cylindrical shells, or a profile), in which case
556
- currents and fluxes are totals through it. Where the real device isn't one,
557
- say so and pick the slice that still tells the true story: a MOSFET's gate stack, read as the
558
- MOS capacitor its channel sits on (pinch-off along the channel is 2D); a BJT along its
559
- emitter–base–collector axis; one particle of a porous battery electrode, or a thin dense film,
560
- rather than the whole porous electrode with diffusion inside its particles (that's a P2D model).
561
- A metal that reacts all along a film of electrolyte (corroding iron under a drop, a crevice's
562
- walls, a well-conducting porous matrix) is a port with `reactions` and an `area` per volume:
563
- an electrode spread through a window, not a face at each end.
564
- - **Check `sol.converged` and `sol.warnings`** on every solve, and pin the CDN version you
565
- tested against.
791
+ - **Draw the closed-form answer alongside the simulation** wherever a textbook limit exists, as
792
+ the templates do. A solver's output looks authoritative even when an input is wrong, and a
793
+ mismatch is the cheapest way to catch it. Where the two should differ, say why.
794
+ - **Stay honestly inside 1D.** Where the real device isn't one-dimensional, say so and pick the
795
+ slice that tells the true story: a MOSFET as its gate stack or its channel, never both (pinch-off
796
+ is 2D); a BJT along its emitter–base–collector axis; one particle of a porous battery electrode
797
+ rather than the whole electrode (that's a P2D model).
566
798
 
567
799
  ## Common mistakes
568
800
 
569
- - **No alignment at a heterointerface.** `interfaces[0] (between … [Si] and … [GaAs]): an
570
- interface between different materials needs an alignment`. Give the face `{ dipole }` or
571
- `{ step: { species: 'e-', value } }` (the conduction-band offset, J/mol), or estimate one from
572
- electron affinities with `vacuumDipole` (docs/alignment.md). `interfaces[f]` is the f-th face
573
- of the stack, counting from the left.
574
- - **A metal face with no φ law.** `… .phi: a face between a conductor and a material with
575
- ε > 0 needs an explicit φ law`. Give `{ phi: { type: 'capacitive', C }, zeroCharge }`, or
576
- `phi: 'neutral'`, or make the electrolyte strictly neutral (`aqueous(names, { epsr: 0 })`)
577
- when double layers don't matter.
578
- - **`ohmic()` on a metal.** Its default holds both e⁻ and h⁺, for semiconductors. On a metal
579
- layer, write `ohmic(V, ['e-'])`.
580
- - **Ions on both sides of a reacting face.** Label them: `'Li+(left) = Li+(right)'`, and give
581
- their link (`species: { 'Li+': 'blocked' }` if they cross only by the reaction).
582
- - **Unit slips.** D in cm²/s, μ° in eV, capacitance in µF/cm², T in °C. `describe(def)` flags
583
- numbers that look like these.
584
- - **A closed system with no amount.** A species that doesn't reach a contact needs `c0`.
585
- - **A strictly neutral `c0` that doesn't balance.** With `epsr: 0`, Σ z·c0 must be zero (traces
586
- included): `… carries a net charge of … mol/m³`. Add the counter-ion's share.
587
- - **A sharp junction between very different solutions** (3 M KCl against 1 µM traces) can't
588
- start: grade it over a few thin layers mixing one solution into the other, as at a frit.
589
- - **A steady solve of a closed cell where every ion can cross.** Its steady state is complete
590
- mixing. To watch a junction or a salt bridge, step in time from the unmixed `c0` (`advance`).
591
- - **Everything floating.** At least one terminal must be held at a voltage `V`.
801
+ - **Errors that say what to give.** A missing alignment at a heterointerface (`vacuumDipole`
802
+ estimates one from electron affinities, docs/alignment.md), a metal face with no φ law,
803
+ everything floating (hold one terminal at a `V`), a species that reaches no contact without a
804
+ `c0`, an unbalanced `c0` in a closed neutral region (add the counter-ion's share), `'2e-'` for
805
+ `'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
807
+ gate a metal layer with a capacitive face, as in the MOS template.
808
+ - **`ohmic()` on a metal.** Its default holds e⁻ and h⁺; on a metal write `ohmic(V, ['e-'])`.
809
+ - **Ions on both sides of a reacting face.** Label them, `'Li+(left) = Li+(right)'`, and block
810
+ them (`species: { 'Li+': 'blocked' }`) if they cross only by the reaction.
811
+ - **Unit slips.** D in cm²/s, μ° in eV, µF/cm², mS/cm², T in °C. `describe(def)` flags them.
592
812
  - **The library's data are for 25 °C** (its semiconductors for 300 K: give `T: 300`). At body
593
- temperature, `T: 310.15` with the same μ° and D is a fair start; D rises about 2% per kelvin
594
- in water.
595
- - **Sign of the current.** `sol.current` is positive toward +x: a forward-biased diode with its
596
- p side on the right carries negative current. A terminal's current is into the device.
597
- - **A membrane potential read from the terminals.** Physiology's V_m is φ_inside − φ_outside:
598
- read it from `sol.phi` (or a `{ x, quantity: 'phi' }` probe in a transient), with the inside on
599
- the right. `sol.terminalVoltage` (and a trace's `voltage`) is V_right − V_left of the contacts,
600
- which at baths are their reference species' levels: it differs from Δφ by that ion's Nernst term.
601
- - **A coefficient without its space.** `'2e- + …'` reads `2e-` as a species name; the error says
602
- to write `2 e-`.
603
- - **Changing c0 with `set()`.** A carried state keeps what it holds; `c0` is only the starting
604
- composition. Make a new `Device` to start over from it.
605
- - **`solve()` before advancing from a `c0` profile.** It replaces the profile with the steady
606
- state. Advance from the new `Device` directly. A profile's x is the device's, in m.
813
+ temperature the same μ° and D are a fair start; D rises about 2% per kelvin in water.
814
+ - **A steady solve of a closed cell where every ion can cross**: its steady state is complete
815
+ mixing. To watch a junction or a salt bridge, `advance()` from the unmixed `c0`.
816
+ - **A sharp junction between very different solutions** (3 M KCl against 1 µM) can't start:
817
+ grade it over a few thin layers, as at a frit.
818
+ - **Changing `c0` with `set()`.** A carried state keeps what it holds: make a new `Device`.
607
819
  - **An unresolved double layer.** If `sol.warnings` says so, refine the grid (`hmin` below the
608
820
  Debye length) or use a neutral model (`epsr: 0`, `phi: 'neutral'`).
821
+
822
+ ## Exports
823
+
824
+ - `driftlet`: `Device`, `units`, `DeviceError`, `SolverError`, and the constants `FARADAY`,
825
+ `GAS_CONSTANT`, `EPS0`, `BOLTZMANN`, `ELEMENTARY_CHARGE`, `AVOGADRO` (there's no thermal-voltage
826
+ constant: V_T = GAS_CONSTANT·T/FARADAY).
827
+ - `driftlet/kit`:
828
+ - building: `build`, `combine`, `layer`, `ohmic`, `bath`, `half`, `level`, `SHE`,
829
+ `vacuumLevel`, `vacuumDipole`, `vacuumZeroCharge`, and the data library (`aqueous`,
830
+ `semiconductor`, `metal`, `IONS`, `SEMICONDUCTORS`, `METALS`, `H2O`, `WATER_EPSR`);
831
+ - sources: `pulse`, `square`, `triangle`, `ramp`, `injector`, `recombination`,
832
+ `photogeneration`;
833
+ - channels: `hodgkinHuxley`, `ghkCurrent`;
834
+ - checking: `describe`, `check`, `polarization` (an electrode's reactions at hypothetical
835
+ metal levels, with the solution held: an Evans diagram), `henderson` and `planck`
836
+ (liquid-junction potentials);
837
+ - pages: `live`, `traces`, `recorder`, `particles`;
838
+ - curated devices: `perovskiteCell`, `PEROVSKITE_SCANS`, `hysteresis`.
839
+ - `driftlet/plot` (a quick look): `bandDiagram`, `levelChart`, `labelParts`, `themeColor`.