driftlet 0.1.0 → 0.2.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.
@@ -0,0 +1,226 @@
1
+ # Statistics
2
+
3
+ A material's **statistics** say how its concentrations follow from its species' potentials.
4
+ Every species *i* has a reduced chemical potential
5
+
6
+ ```math
7
+ \zeta_i = (\bar\mu_i - z_i F \phi - \mu^\circ_i) / RT
8
+ ```
9
+
10
+ and a statistics model gives the concentrations $`c(\zeta)`$ of the species it covers. Species
11
+ that aren't in any model are ideal, $`c = c_{\mathrm{ref}} \cdot e^\zeta`$, which is the default and
12
+ the right choice for dilute species. Non-ideal models describe degenerate carriers, crowding,
13
+ filling of a finite set of sites, activity coefficients, and intercalation hosts.
14
+
15
+ Three rules hold for every model:
16
+
17
+ - **The dilute limit is ideal.** Every model tends to $`c_{\mathrm{ref}} \cdot e^\zeta`$ as $`c \to 0`$,
18
+ so `mu0` and `cRef` keep one meaning (the dilute, Henry's-law reference state) whatever the
19
+ statistics. Changing a material's statistics changes only its non-dilute behaviour.
20
+ - **The Jacobian is a chemical capacitance.** $`K = \partial c/\partial\zeta`$ is the Hessian of a
21
+ convex potential (the grand potential density $`P(\zeta)`$, with $`c = \partial P/\partial\zeta`$),
22
+ so it's symmetric and positive definite. $`RT \cdot K`$ is the chemical capacitance matrix per
23
+ volume. Models whose free energy isn't convex (a miscibility gap) are refused, because they
24
+ need phase separation (Cahn–Hilliard, on the [roadmap](../ROADMAP.md)).
25
+ - **D keeps its meaning.** Fluxes are always $`N = -(D c/RT)\nabla\bar\mu`$, with $`D`$ the
26
+ dilute-limit coefficient (constant mobility). The chemical diffusivity you'd measure is $`D`$
27
+ times the thermodynamic factor $`\partial\ln a/\partial\ln c`$, the generalised Einstein
28
+ relation. See [conventions](conventions.md#transport).
29
+
30
+ ## Declaring statistics
31
+
32
+ ```js nocheck
33
+ materials: {
34
+ Si: {
35
+ epsr: 11.7,
36
+ species: { 'e-': { D: 36e-4, mu0: 0, cRef: Nc }, 'h+': { D: 12e-4, mu0: units.eV(1.12), cRef: Nv } },
37
+ statistics: [{ type: 'fermi-dirac', species: ['e-', 'h+'] }],
38
+ },
39
+ }
40
+ ```
41
+
42
+ `statistics` is a list of models, each naming the species it covers. A species belongs to at
43
+ most one model.
44
+
45
+ | Model | Species | $`c(\zeta)`$ | Parameters |
46
+ |---|---|---|---|
47
+ | `fermi-dirac` | any, each independently | $`c_{\mathrm{ref}} \mathcal{F}_{1/2}(\zeta)`$ (3D band); $`c_{\mathrm{ref}} \ln(1 + e^\zeta)`$ with `order: 0` (2D) | `order`: 1/2 (default) or 0 |
48
+ | `lattice` | any, sharing sites | $`c_a = c_{\mathrm{max}} w_a / (1 + \sum w)`$, $`w_a = (c_{\mathrm{ref},a}/c_{\mathrm{max}}) e^{\zeta_a}`$ | `cMax`, site density, mol/m³ |
49
+ | `redlich-kister` | one | on its own sites: $`\zeta = \ln(x/(1-x)) + \ln(c_{\mathrm{max}}/c_{\mathrm{ref}}) + (g(x) - g(0))/RT`$, $`x = c/c_{\mathrm{max}}`$ | `cMax`; `A`, J/mol |
50
+ | `debye-huckel` | charged | $`c_{\mathrm{ref}} e^\zeta / \gamma`$, $`\ln\gamma_i = -z_i^2 \ell_B \kappa / (2(1 + \kappa a))`$ | `epsr` (solvent, for the theory); `a`, m |
51
+ | `insertion` | [ion, carrier], $`\varepsilon = 0`$ host | occupancy from the neutral combination (below) | `cMax`; `A` or `ocv` |
52
+ | `custom` | any | your function | `evaluate(zeta) → { c, dcdzeta }` |
53
+
54
+ ### Fermi–Dirac
55
+
56
+ For electrons and holes, $`c_{\mathrm{ref}}`$ is the effective density of states and $`\mu^\circ`$ the
57
+ band edge, as for ideal statistics, and $`c = N_c \mathcal{F}_{1/2}(\zeta)`$ with the normalised
58
+ Fermi–Dirac integral ($`\mathcal{F}_j \to e^\zeta`$ for $`\zeta \to -\infty`$). driftlet evaluates
59
+ $`\mathcal{F}_{1/2}`$ and $`\mathcal{F}_{-1/2}`$ (its derivative) to ~1e-14 relative, so equilibrium
60
+ carrier densities are exact. `order: 0` is a two-dimensional density of states, and it's exact
61
+ in closed form.
62
+
63
+ ### Lattice gas
64
+
65
+ The listed species share $`c_{\mathrm{max}}`$ sites per volume and each occupies one, so no
66
+ concentration can exceed $`c_{\mathrm{max}}`$. With both ions of an electrolyte on one lattice, this
67
+ is the Bikerman / Kilic–Bazant–Ajdari model of a crowded double layer. At large potentials the
68
+ counter-ion saturates at $`c_{\mathrm{max}}`$, and the diffuse charge grows as $`\sqrt{\psi}`$ instead
69
+ of exponentially. With one species it's Langmuir filling of sites. The dilute reference
70
+ $`c_{\mathrm{ref}}`$ is kept. To use the half-filling convention of site models
71
+ ($`e^\zeta = x/(1-x)`$), set `cRef` equal to `cMax`.
72
+
73
+ ### Redlich–Kister
74
+
75
+ One species on its own $`c_{\mathrm{max}}`$ sites, with the excess Gibbs energy of mixing per site
76
+ $`x(1-x) \sum A_k (1-2x)^k`$. $`g(x)`$ in the table is its $`x`$-derivative, the excess chemical
77
+ potential. A single `A: [Ω]` is the regular solution,
78
+ $`\mu = \mu^\circ + RT \ln(x/(1-x)) + \Omega(1-2x) + \mathrm{const}`$. It's convex only for
79
+ $`\Omega < 2RT`$, which is checked at construction.
80
+
81
+ The table form is referred to the dilute limit. The half-filling convention of the battery
82
+ literature, $`\mu = \mu^\circ_{1/2} + RT \ln(x/(1-x)) + \Omega(1-2x)`$, corresponds to
83
+ `cRef = cMax` and `mu0` $`= \mu^\circ_{1/2} + A_0`$ (the excess at $`x = 0`$, $`\sum A_k`$ in general).
84
+
85
+ ### Debye–Hückel
86
+
87
+ Extended Debye–Hückel activity coefficients for dilute electrolytes:
88
+ $`\ln\gamma_i = -z_i^2 \ell_B \kappa / (2(1 + \kappa a))`$, with the Bjerrum length
89
+ $`\ell_B = e^2/(4\pi\varepsilon kT)`$ and $`\kappa^2 = F^2 \sum z^2 c / (\varepsilon RT)`$ from the
90
+ solvent permittivity `epsr` (which defaults to the material's, if that's non-zero) and a
91
+ distance of closest approach `a` (default 0, the limiting law). It stops being convex at high
92
+ ionic strength, and the solver then reports a failure.
93
+
94
+ Single-ion activity coefficients are a convention: only neutral combinations (the mean
95
+ activity $`\gamma_\pm`$) are measurable. In a strictly neutral ($`\varepsilon = 0`$) material only
96
+ those combinations matter, so that's where this model belongs. With $`\varepsilon > 0`$, the
97
+ resolved double layers would depend on the convention, so driftlet warns.
98
+
99
+ ### Insertion hosts: neutral-combination statistics
100
+
101
+ In an intercalation host, an inserted ion enters together with its compensating electronic
102
+ carrier, e.g. $`\mathrm{Li}^+ + \mathrm{e}^-`$. Only their neutral combination has a well-defined
103
+ chemical potential, $`\mu_{\mathrm{Li}} = \bar\mu_{\mathrm{Li}^+} + \bar\mu_{\mathrm{e}^-}`$, and the
104
+ host's composition depends on nothing else. That's what an OCV curve measures, and the mutual
105
+ chemical capacitance of the pair is the slope of that curve.
106
+
107
+ The `insertion` model describes exactly this, in a strictly neutral host (`epsr: 0`) whose
108
+ only charged species are the pair `[ion, carrier]`. For an ion of charge $`z`$ and a carrier of
109
+ charge $`-z/\nu`$:
110
+
111
+ ```math
112
+ \begin{aligned}
113
+ \zeta_{\mathrm{comb}} &= \zeta_{\mathrm{ion}} + \nu \zeta_{\mathrm{carrier}} \quad (\phi\text{ cancels}) \\
114
+ c_{\mathrm{ion}} &= s(\zeta_{\mathrm{comb}}), \qquad c_{\mathrm{carrier}} = \nu s + \text{background}
115
+ \end{aligned}
116
+ ```
117
+
118
+ The occupancy $`s = c_{\mathrm{max}} x`$ comes either from a Redlich–Kister isotherm (`A`, as above,
119
+ referred to the ion's dilute reference) or from a table:
120
+
121
+ ```js nocheck
122
+ statistics: [{
123
+ type: 'insertion',
124
+ species: ['Li+', 'e-'],
125
+ cMax: 30000, // sites, mol/m³
126
+ ocv: { x: [...], E: [...], muRef: 0 }, // E against a reference where μ_Li = muRef
127
+ }]
128
+ ```
129
+
130
+ The table gives the open-circuit voltage $`E(x)`$ of the host against a reference electrode in
131
+ which the combination has chemical potential `muRef` (J/mol), so that
132
+ $`\mu_{\mathrm{ion}} + \nu \mu_{\mathrm{carrier}} = \mathtt{muRef} - z F E(x)`$. Against Li metal,
133
+ on the usual table convention, `muRef` is 0. $`x`$ must increase and $`E`$ must strictly decrease.
134
+ A flat plateau means phase separation, which isn't supported yet. The table is read as
135
+ $`\zeta = \ln(x/(1-x)) + r(x)`$: the residual $`r`$, the host's non-ideality, is a shape-preserving
136
+ cubic between points and continues linearly beyond them. Ideal and regular-solution curves are
137
+ therefore reproduced exactly, and $`x`$ stays within $`(0, 1)`$ at any potential. The interpolated
138
+ curve is checked for monotonicity at construction.
139
+
140
+ Because every charged species sits in a neutral combination, **$`\phi`$ is undefined** in such a
141
+ host and is reported as `NaN`. Its faces must be `neutral` (the default next to such a host),
142
+ and its contacts `bulk` or `neutral`. The host's `fixedCharge` is balanced by background
143
+ carriers, $`\mathtt{background} = -\mathtt{fixedCharge}/(z_{\mathrm{carrier}} F)`$, which must be
144
+ non-negative. This is a jellium description of the host's own electrons, which keep it
145
+ conducting when it's empty. With no fixed charge, the carriers are only those that came in with
146
+ the ions. The carrier's `cRef` plays no part (only the combination's $`\mu^\circ`$ and the ion's
147
+ `cRef` enter), but it must still be given.
148
+
149
+ An insertion host belongs with [conductor regions](device.md#materials) more than with the other
150
+ statistics here. Both are conductors with no $`\phi`$ of their own:
151
+ - **A metal** is the one-carrier case: its Fermi level is its only unknown.
152
+ - **An insertion host** is the two-carrier case: only the neutral combination's potential sets
153
+ the composition, and the split between ion and carrier is free.
154
+
155
+ A general mixed conductor with more mobile species needs fully populated mutual capacitance and
156
+ conductance matrices, a different model again (see the [roadmap](../ROADMAP.md)).
157
+
158
+ ### Custom
159
+
160
+ ```js nocheck
161
+ statistics: [{
162
+ type: 'custom',
163
+ species: ['A', 'B'],
164
+ evaluate: (zeta) => ({ c: [cA, cB], dcdzeta: [[dA_dA, dA_dB], [dB_dA, dB_dB]] }),
165
+ }]
166
+ ```
167
+
168
+ `evaluate` gets the species' $`\zeta`$ (in the listed order) and returns their concentrations and
169
+ the Jacobian $`\partial c/\partial\zeta`$. The Jacobian must be symmetric and positive definite,
170
+ and that's checked at $`\zeta = 0`$ when the device is built. A device with a function in it isn't
171
+ plain data, so it can't be posted to a Web Worker. Build it inside the worker instead.
172
+
173
+ ## Transport with non-ideal statistics
174
+
175
+ The flux law is $`N = -(D c/RT)\nabla\bar\mu`$ for every model. Writing $`\ln c = \zeta - \mathrm{ex}`$
176
+ with the excess $`\mathrm{ex} = \zeta - \ln(c/c_{\mathrm{ref}})`$ (zero for ideal statistics), the
177
+ excess acts like an extra potential. The Scharfetter–Gummel flux keeps its form with
178
+ $`\Delta = z\Delta\hat\phi + \Delta\mathrm{ex}`$ (see [numerics](numerics.md#fluxes)), so it's still
179
+ exactly zero at equilibrium. It's second-order accurate in the cell size (and exact for a single
180
+ diffusing species on a lattice).
181
+
182
+ Reaction rates keep their concentration-based prefactors (mass action in $`c`$, and
183
+ $`c/c_{\mathrm{ref}}`$ in the Butler–Volmer forms), while the driving force is always the affinity
184
+ from $`\bar\mu`$. So equilibrium is exact for any statistics, and only the kinetics away from it
185
+ depend on that choice of prefactor.
186
+
187
+ Cross-species transport coefficients (an Onsager matrix) aren't included: each species moves
188
+ down its own $`\bar\mu`$. They should arrive together with cross chemical capacitances, as two
189
+ halves of the same network ([roadmap](../ROADMAP.md)). Note that $`K`$ itself can already be
190
+ non-diagonal, e.g. on a shared lattice.
191
+
192
+ ## Example: a lithium host against its OCV
193
+
194
+ ```js
195
+ import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
196
+
197
+ const RT = GAS_CONSTANT * 298.15;
198
+ const x = [0.05, 0.2, 0.4, 0.6, 0.8, 0.95];
199
+ const E = x.map((v) => 0.4 - (RT / FARADAY) * Math.log(v / (1 - v))); // an ideal-solution OCV, V vs Li
200
+
201
+ const dev = new Device({
202
+ species: [
203
+ { name: 'Li+', z: 1 },
204
+ { name: 'e-', z: -1 },
205
+ ],
206
+ materials: {
207
+ host: {
208
+ epsr: 0,
209
+ species: { 'Li+': { D: 1e-14, mu0: 0, cRef: 30000 }, 'e-': { D: 1e-4, mu0: 0, cRef: 30000 } },
210
+ statistics: [{ type: 'insertion', species: ['Li+', 'e-'], cMax: 30000, ocv: { x, E, muRef: 0 } }],
211
+ },
212
+ },
213
+ regions: [{ material: 'host', length: 1e-6 }],
214
+ contacts: {
215
+ // Li⁺ from a lithium reference (via an electrolyte) on the left; electrons from a current
216
+ // collector on the right, whose voltage is the electrode potential vs Li.
217
+ left: { V: 0, terminal: 'Li+', species: { 'Li+': 'equilibrium' }, phi: 'bulk' },
218
+ right: { V: 0.4, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'bulk' },
219
+ },
220
+ });
221
+ for (const V of [0.5, 0.4, 0.3]) {
222
+ dev.set({ contacts: { right: { V } } });
223
+ const sol = dev.solve();
224
+ console.log(`E = ${V} V: x = ${(sol.c['Li+'][5] / 30000).toFixed(4)}`); // 0.0200, 0.5000, 0.9800
225
+ }
226
+ ```
@@ -0,0 +1,259 @@
1
+ # Reading level diagrams
2
+
3
+ driftlet draws its results as level diagrams: voltages against position, on one axis, for the
4
+ electrons in the metals, for the reactions in the solutions, and for every charged species. This
5
+ page explains what each line means and why it's drawn that way. The first half covers the
6
+ electronic levels, Fermi levels and redox levels, which connect directly to electrode potentials
7
+ and other familiar quantities. The second half covers the species voltages of every charged
8
+ species, ions included. For how to draw them, see [the kit's plotting helpers](kit.md#plotting).
9
+
10
+ ## Electron levels
11
+
12
+ ### The Fermi level
13
+
14
+ An electron's electrochemical potential $`\bar\mu_{\mathrm{e}^-}`$, written as a voltage, is
15
+
16
+ ```math
17
+ V_{\mathrm{e}^-} = -\bar\mu_{\mathrm{e}^-} / F
18
+ ```
19
+
20
+ It's the voltage of ordinary circuits. It's what a voltmeter reads between two terminals, it
21
+ equalises between metals wired together, it drops along a resistor by $`IR`$, and a cell's voltage
22
+ is the difference between its terminals' $`V_{\mathrm{e}^-}`$. In a metal it's the Fermi level, in
23
+ volts. A terminal's `V` in driftlet is this voltage, for an electron contact. In a semiconductor
24
+ out of equilibrium, electrons and holes each have their own (the quasi-Fermi levels, below).
25
+
26
+ ### Redox levels
27
+
28
+ A half-reaction $`\mathrm{Ox} + z\,\mathrm{e}^- \rightleftharpoons \mathrm{Red}`$ is in equilibrium when
29
+ $`\bar\mu_{\mathrm{Ox}} + z \bar\mu_{\mathrm{e}^-} = \bar\mu_{\mathrm{Red}}`$. That fixes an electron
30
+ level, even where no free electrons exist:
31
+
32
+ ```math
33
+ V_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red}) = (\bar\mu_{\mathrm{Ox}} - \bar\mu_{\mathrm{Red}}) / (zF)
34
+ ```
35
+
36
+ This is the couple's **redox level** (sometimes called its redox Fermi level). At each point
37
+ where Ox and Red are present, it's where an electrode exchanging electrons by that couple would
38
+ sit if it were in equilibrium with the local composition. `level(sol, half)` evaluates it at
39
+ every node.
40
+
41
+ It belongs on the same axis as the metals' Fermi levels, because then the things that matter
42
+ are gaps you can point at:
43
+
44
+ - **At an electrode**, the step from the metal's $`V_{\mathrm{e}^-}`$ to the couple's level at the
45
+ face is the surface overpotential. It's zero at equilibrium, and its size and sign drive the
46
+ reaction's kinetics. Where the metal sits higher (in volts) than the couple's level, electrons
47
+ leave the solution for the metal: oxidation, an anodic current.
48
+ - **Through the solution**, the couple's level slopes wherever current flows. Part of the slope
49
+ is ohmic drop and part is concentration polarisation: the couple's composition changing near
50
+ the electrode.
51
+ - **Several couples** in one solution each have their own level. The levels agree only if the
52
+ couples trade electrons fast enough to reach equilibrium with each other, which often they
53
+ don't. An electrode that exchanges with several couples at once settles between their levels:
54
+ a mixed potential.
55
+
56
+ ### Standard redox levels
57
+
58
+ With every participant at its reference concentration, the same formula gives the couple's
59
+ **standard redox level** $`V^\circ_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red})`$
60
+ (`level(sol, half, { standard: true })`). The redox level sits above or below it by the activity
61
+ term, which makes the Nernst equation local:
62
+
63
+ ```math
64
+ V_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red}) = V^\circ_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red}) + \frac{RT}{zF} \ln\frac{a_{\mathrm{Ox}}}{a_{\mathrm{Red}}}
65
+ ```
66
+
67
+ The standard level moves with the solution's $`\phi`$, so it slopes with the ohmic drop. The gap
68
+ between the two lines is purely composition.
69
+
70
+ ### Electrode potentials
71
+
72
+ The familiar potentials are gaps between these levels, measured from one more standard level:
73
+ the hydrogen couple's, $`V^\circ_{\mathrm{e}^-}(\mathrm{SHE})`$, for 2 H⁺ + 2 e⁻ ⇌ H₂ at unit
74
+ activities.
75
+
76
+ ```math
77
+ \begin{aligned}
78
+ E &= V_{\mathrm{e}^-}(\text{electrode}) - V^\circ_{\mathrm{e}^-}(\mathrm{SHE}) && \quad\text{the electrode potential, "vs SHE"} \\
79
+ E_{\mathrm{eq}} &= V_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red}) - V^\circ_{\mathrm{e}^-}(\mathrm{SHE}) && \quad\text{the couple's equilibrium potential} \\
80
+ E^\circ &= V^\circ_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red}) - V^\circ_{\mathrm{e}^-}(\mathrm{SHE}) && \quad\text{its standard potential} \\
81
+ \eta &= E - E_{\mathrm{eq}} && \quad\text{the overpotential}
82
+ \end{aligned}
83
+ ```
84
+
85
+ The Nernst equation, $`E_{\mathrm{eq}} = E^\circ + (RT/zF) \ln(a_{\mathrm{Ox}}/a_{\mathrm{Red}})`$, is the
86
+ floating form above with the SHE's level subtracted from both sides.
87
+
88
+ With the data library's standard potentials, which follow the usual tables
89
+ ($`\mu^\circ_{\mathrm{H}^+} = 0`$ and $`\mu_{\mathrm{H_2}} = 0`$), the SHE's standard level in an
90
+ aqueous solution is $`\phi`$ itself. So drawing $`\phi`$ (`traces(sol, { phi: true })`) draws the
91
+ SHE's level, and the demos label it that way.
92
+
93
+ One caution: $`V^\circ_{\mathrm{e}^-}(\mathrm{SHE})`$ is a level in the solution, and it slopes
94
+ wherever current flows. So "vs SHE" needs a place. A reference electrode samples the level where
95
+ its tip sits, which is why practitioners correct for iR drop, use a Luggin capillary, or add
96
+ supporting electrolyte to flatten the bulk. In a cell polarised from wall to wall there's no flat
97
+ bulk and no single $`E`$, but the levels are all still there to read.
98
+
99
+ For example, platinum at open circuit in a ferric/ferrous solution:
100
+
101
+ ```js
102
+ import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
103
+ import { build, layer, ohmic, bath, half, level, aqueous, metal } from 'driftlet/kit';
104
+
105
+ // Platinum at open circuit in 2 mM Fe³⁺ and 8 mM Fe²⁺, in 0.5 M KCl.
106
+ const iron = half('Fe3+ + e- = Fe2+');
107
+ const def = build({
108
+ library: [aqueous(['K+', 'Cl-', 'Fe3+', 'Fe2+'], { epsr: 0 }), metal('Pt')],
109
+ stack: [
110
+ ohmic({ I: 0 }, ['e-']),
111
+ layer('Pt', 1e-6),
112
+ { reactions: [{ ...iron, k0: 1e-3, alpha: 0.5 }] },
113
+ layer('water', 10e-6),
114
+ bath({ 'K+': 500, 'Cl-': 522, 'Fe3+': 2, 'Fe2+': 8 }, 'Cl-', 0),
115
+ ],
116
+ grid: { hmin: 10e-9, hmax: 0.5e-6 },
117
+ });
118
+ const sol = new Device(def).solve();
119
+ const g = sol.x.length - 1; // in the bulk
120
+ const fermi = sol.terminals.left.V; // the platinum's V_e⁻
121
+ const she = sol.phi[g]; // V°_e⁻(SHE), with the library's data
122
+ const E = fermi - she, Eeq = level(sol, iron)[g] - she, E0 = level(sol, iron, { standard: true })[g] - she;
123
+ const VT = (GAS_CONSTANT * 298.15) / FARADAY;
124
+ console.log(`E = ${E.toFixed(4)} V vs SHE, E_eq = ${Eeq.toFixed(4)} V, Nernst: ${(E0 + VT * Math.log(2 / 8)).toFixed(4)} V`);
125
+ ```
126
+
127
+ A cell's voltage splits the same way along the electrons' path. It's the difference between
128
+ the two terminals' $`V_{\mathrm{e}^-}`$: at open circuit, the gap between the two couples' levels;
129
+ under load, less each electrode's overpotential and the solution's share. The
130
+ [Daniell demo](https://markblundeberg.github.io/driftlet/demos/daniell.html) draws this and adds it up.
131
+
132
+ ### How they're drawn
133
+
134
+ | line | what it is |
135
+ |---|---|
136
+ | thick, solid | a species voltage $`V_i`$: for electrons, the Fermi level |
137
+ | thick, dashed | a redox level $`V_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red})`$ |
138
+ | thin, dashed | a standard redox level $`V^\circ_{\mathrm{e}^-}(\mathrm{Ox}/\mathrm{Red})`$ |
139
+ | thin, solid | a standard level $`V^\circ_i`$ (below) |
140
+ | thin, dotted, grey | $`\phi`$, on request: across an insulator, or as the SHE's level (below) |
141
+
142
+ Electrons are steel blue, cations (and holes) warm colours, anions cool colours, and redox levels
143
+ blue-violet.
144
+
145
+ ## Species voltages for every charged species
146
+
147
+ The Fermi level's definition works for any charged species i:
148
+
149
+ ```math
150
+ \begin{aligned}
151
+ V_i &= \bar\mu_i / (z_i F) && \quad\text{the species voltage} \\
152
+ V^\circ_i &= \phi + \mu^\circ_i / (z_i F) && \quad\text{its standard level}
153
+ \end{aligned}
154
+ ```
155
+
156
+ $`V_i`$ is a way of drawing $`\bar\mu_i`$, not new physics: the equations, the docs and the API work
157
+ in $`\bar\mu_i`$, which also covers neutral species (where $`V_i`$ is undefined). For electrons, $`V_i`$
158
+ is the Fermi level. For an ion, it's the voltage an electrode reversible to that ion would read: a
159
+ silver wire in equilibrium with the solution's Ag⁺ reads $`V_{\mathrm{Ag}^+}`$. The standard level
160
+ is where $`V_i`$ would sit at the reference concentration, so for ideal species
161
+
162
+ ```math
163
+ V_i - V^\circ_i = \frac{RT}{z_i F} \ln\frac{c_i}{c_{\mathrm{ref},i}}
164
+ ```
165
+
166
+ and for non-ideal ones the activity replaces $`c_i/c_{\mathrm{ref},i}`$.
167
+
168
+ Drawing every species this way, ions alongside electrons, shows things that are otherwise hard
169
+ to see:
170
+
171
+ - **Each species' driving force is the slope of its own line.** A species moves down its own
172
+ $`\bar\mu_i`$, drift and diffusion together, so on the voltage plot the current it carries flows
173
+ down slopes in its $`V_i`$ (cations and holes move downhill, electrons and anions uphill), and in
174
+ equilibrium its $`V_i`$ is flat. There's no need to split that one slope into an electric field
175
+ and a concentration gradient (a split that depends on how $`\phi`$ was chosen, and so means little
176
+ on its own).
177
+ - **Interfaces read at a glance.** Where a species crosses a face freely, its $`V_i`$ is
178
+ continuous. A step in $`V_i`$ means something resists its transfer and dissipates energy there.
179
+ The standard levels step at faces along with $`\phi`$ (Donnan potentials, liquid junctions,
180
+ dipoles), but those steps are bookkeeping: they cancel from anything measured.
181
+ - **Reactions are levels lining up.** A redox level is a weighted combination of species
182
+ voltages. For Fe³⁺ + e⁻ ⇌ Fe²⁺,
183
+ $`V_{\mathrm{e}^-}(\mathrm{Fe}^{3+}/\mathrm{Fe}^{2+}) = 3V_{\mathrm{Fe}^{3+}} - 2V_{\mathrm{Fe}^{2+}}`$.
184
+ For a plating couple with $`\mu_{\mathrm{M}} = 0`$, the couple's level coincides with the ion's
185
+ own line: $`V_{\mathrm{e}^-}(\mathrm{Ag}^+/\mathrm{Ag}) = V_{\mathrm{Ag}^+}`$.
186
+ - **Semiconductor band diagrams are already this picture.** $`V_{\mathrm{e}^-}`$ and
187
+ $`V_{\mathrm{h}^+}`$ are the quasi-Fermi levels, and their standard levels are the band edges.
188
+ Since $`V = -E/e`$ for electrons, the conduction band sits *below* the valence band in volts.
189
+ - **Small-signal response has the same rails.** Jamnik and Maier's exact equivalent circuit for
190
+ transport has a rail for each species' $`\bar\mu_i/(z_i F)`$, with resistors along it for
191
+ transport and chemical capacitances from it to a $`\phi`$ rail for storage (J. Jamnik and J. Maier,
192
+ [Phys. Chem. Chem. Phys. 3, 1668 (2001)](https://doi.org/10.1039/b100180i)). The
193
+ [impedance demo](https://markblundeberg.github.io/driftlet/demos/impedance.html) draws the voltages on those rails.
194
+
195
+ Different species' levels sit volts apart, because their $`\mu^\circ`$ differ by hundreds of
196
+ kJ/mol. The gap between two species' lines carries a constant fixed by convention (below), so
197
+ moving one species' lines up or down loses nothing. `traces()` does this with `shifts`, and marks
198
+ the shifted lines ⌇.
199
+
200
+ ## What's left out, and why
201
+
202
+ ### φ
203
+
204
+ The electrostatic potential $`\phi`$ isn't drawn by default, though it's in the same volts. Inside a
205
+ material, $`\phi`$ is a bookkeeping convention: each material's standard potentials fix where its
206
+ $`\phi`$ sits, and any consistent choice gives the same physics (see [conventions](conventions.md)).
207
+ Its steps between materials can't be measured, and inside a metal it has no measurable meaning at
208
+ all. And while the electron diagram is in volts, every line on it should be an electronically
209
+ meaningful level; $`-e\phi`$ isn't the energy of any electron.
210
+
211
+ What $`\phi`$ would show is drawn anyway, more usefully. The standard levels
212
+ $`V^\circ_i = \phi + \mu^\circ_i/(z_i F)`$ of a material move together with its $`\phi`$, so their
213
+ bending ladder carries the same variation in space within each material. Unlike $`\phi`$, the ladder
214
+ means something across materials too: its offsets at a face are band offsets and the like, which
215
+ belong to the interface and can be measured.
216
+
217
+ There are two places where $`\phi`$ is the right line to draw:
218
+
219
+ - **In an insulator** with no mobile charged species, there are no species levels to draw. Band
220
+ edges can stand in, unpopulated and with no Fermi level, but when the point is that nothing
221
+ mobile lives there, the plain sloped $`\phi`$ line says it best: its slope is the field. It's also
222
+ harmless to the reader, since there's no interfacial step in $`\phi`$ in view to mistake for
223
+ something measurable, just a lone line connected to nothing else. The
224
+ [MOS demo](https://markblundeberg.github.io/driftlet/demos/mos.html) draws $`\phi`$ across its oxide this way. It draws $`\phi`$ in its
225
+ silicon too, deliberately anchored at an arbitrary offset from the band edges and stepping at
226
+ the oxide's face, to show the band edges riding on it while its position is a convention.
227
+ - **In vacuum**, $`-e\phi`$ is exactly what it claims to be: the energy of an electron at rest there.
228
+
229
+ `traces(sol, { phi: true })` draws $`\phi`$ everywhere it's defined, and `phi: ['SiO2']` only in the
230
+ regions named. Besides the MOS demo, the demos draw it only as the SHE's standard level in water,
231
+ which is what it equals with the library's data.
232
+
233
+ ### A reference
234
+
235
+ Nor is anything drawn against a reference: not $`E(x)`$ vs SHE, not energies from the vacuum. The
236
+ diagrams are reference-free. The simulation grounds one terminal at 0 V because it has to put the
237
+ zero somewhere, but nothing depends on where: shift the whole diagram up or down and the physics
238
+ is unchanged. So pick your own reference. Every vertical gap on the electron diagram is
239
+ physical, so choose a level at a point (the SHE's standard level in the stirred bulk, a reference
240
+ electrode's Fermi level, a band edge deep in a contact) and read everything else against it.
241
+
242
+ That's also why "vs SHE" and "from the vacuum" need a place. The SHE's standard level and the
243
+ vacuum level both vary in space wherever there's current or charge: the SHE's slopes with the
244
+ ohmic drop in a solution, and the vacuum level follows the electrostatics. Plotting $`E(x)`$
245
+ against the local SHE level would subtract a sloping line from every other line, mixing changes
246
+ in the reference into changes in the thing measured.
247
+
248
+ On the species diagram there's one technicality. A gap $`V_i - V_j`$ between two species at a point
249
+ is free of $`\phi`$ (it cancels), so its changes in space and time are physical. Its absolute value
250
+ carries one constant for that pair, though, fixed once by the chemical convention for the
251
+ elements' zeros ($`\mu = 0`$ for each element in its standard state, as in every table). For
252
+ instance, $`V_{\mathrm{Ag}^+} - V_{\mathrm{e}^-} = \mu_{\mathrm{Ag}}/F`$ at equilibrium, which is
253
+ zero only because the convention says so. Electron levels carry no such constant, which is why
254
+ the redox levels are drawn as electron levels: the conventions cancel out of
255
+ $`V_{\mathrm{e}^-}(\mathrm{Ag}^+/\mathrm{Ag}) = V_{\mathrm{Ag}^+} - \mu_{\mathrm{Ag}}/F`$.
256
+
257
+ These diagrams are developed at length, from what a voltmeter measures through semiconductors,
258
+ electrolytes and electrochemical cells, in
259
+ [Electrochemical species band diagrams](https://marklundeberg.com/esbd/).
package/llms.txt CHANGED
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
14
14
  From a CDN, pin the version:
15
15
 
16
16
  ```js nocheck
17
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.1.0/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.1.0/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.1.0/src/plot.js';
17
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/index.js';
18
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/kit.js';
19
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/plot.js';
20
20
  ```
21
21
 
22
22
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
@@ -44,7 +44,8 @@ From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
44
44
  - **Exports.** `driftlet`: `Device`, `units`, and the constants `FARADAY`, `GAS_CONSTANT`,
45
45
  `EPS0`, `BOLTZMANN`, `ELEMENTARY_CHARGE`, `AVOGADRO` (there's no thermal-voltage constant:
46
46
  V_T = GAS_CONSTANT·T/FARADAY). `driftlet/kit`: `build`, `layer`, `ohmic`, `bath`, `half`,
47
- `level`, `SHE`, `describe`, `live`, `traces`, the data library (`aqueous`, `semiconductor`,
47
+ `level`, `SHE`, `describe`, `live`, `traces`, the sources (`pulse`, `square`, `triangle`, `ramp`,
48
+ `injector`, `recombination`), the data library (`aqueous`, `semiconductor`,
48
49
  `metal`, `IONS`, …) and the vacuum-level helpers. `driftlet/plot`: `bandDiagram`, `levelChart`, `labelParts` (labels mark subscripts as `V_{e⁻}`).
49
50
  - **Reactions** are written as equations, `{ equation: 'e- + h+ = 0', kf: { Si: 1e8 } }`, in
50
51
  `new Device`, `build()` and `set()` alike. A coefficient is a number and a space (`2 e-`).
@@ -185,6 +186,42 @@ const dev = new Device(electrode(rest.terminals.left.V + 0.2));
185
186
  console.log(`anodic current ${dev.solve().terminals.left.current.toFixed(2)} A/m²`);
186
187
  ```
187
188
 
189
+ ## Template: a transient, with a pulse injected sideways
190
+
191
+ A **port** feeds a window of nodes inside a region from the side (a point contact, an injector,
192
+ a MOS channel's source and drain), held at a voltage or driven by a current; `injector()` writes
193
+ one that spreads a driven current evenly. Transients run with `advance(t)`, adaptively, from the
194
+ steady state that `solve()` finds at time 0, and `probes` read the device at points inside it.
195
+
196
+ ```js
197
+ import { Device, units, FARADAY } from 'driftlet';
198
+ import { build, layer, ohmic, semiconductor, pulse, injector, recombination } from 'driftlet/kit';
199
+
200
+ // A pulse of holes injected into an n-type germanium bar under a field (Haynes–Shockley), and
201
+ // the hole density read 1 mm downstream as it drifts past.
202
+ const L = 4e-3, ND = units.perCm3(1e15), E = 1000; // m, mol/m³, V/m (10 V/cm)
203
+ const width = 0.5e-6, I = (0.05 * ND * FARADAY * 50e-6) / width; // s; A/m²: 5% of n₀ over 50 µm
204
+ const def = build({
205
+ T: 300,
206
+ library: [semiconductor('Ge')],
207
+ stack: [ohmic(E * L), layer('Ge', L, { name: 'bar', donors: ND }), ohmic(0)], // holes drift toward +x
208
+ bulkReactions: [recombination({ material: 'Ge', tau: 20e-6, majority: ND })], // the minority lifetime
209
+ ports: [injector({ region: 'bar', from: 0.5e-3, to: 0.55e-3, species: 'h+', I: pulse({ width, amplitude: I, rise: 1e-9 }) })],
210
+ grid: { hmin: units.um(10), hmax: units.um(10) },
211
+ });
212
+ const dev = new Device(def);
213
+ const rest = dev.solve(); // the steady state at t = 0 (solve() doesn't advance time)
214
+ // In a page, call advance(tNext, { budgetMs: 12, probes }) once per animation frame, carrying on
215
+ // next frame while sol.done is false. Probes read at accepted steps; dtMax keeps them close.
216
+ const run = dev.advance(10e-6, { dtMax: 20e-9, probes: [{ x: 1.525e-3, species: 'h+' }] });
217
+ const seen = run.trace.probes[0], k = seen.indexOf(Math.max(...seen));
218
+ const tArrive = run.trace.t[k] - width / 2; // from the pulse's middle
219
+ 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
220
+ ```
221
+
222
+ Transients cost more where a carrier's density spans many decades (minority holes in silicon
223
+ ahead of the pulse) and at strong injection (Δp near n₀); budget for that, or coarsen the grid.
224
+
188
225
  ## Template: a live page with a slider
189
226
 
190
227
  `live()` merges slider moves made during a solve, warm-starts each solve and keeps the last
@@ -199,9 +236,9 @@ a Web Worker.
199
236
  <span id="readout"></span>
200
237
  <div id="figure"></div>
201
238
  <script type="module">
202
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.1.0/src/index.js';
203
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.1.0/src/kit.js';
204
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.1.0/src/plot.js';
239
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/index.js';
240
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/kit.js';
241
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.2.0/src/plot.js';
205
242
 
206
243
  const def = build({
207
244
  T: 300,
@@ -236,13 +273,15 @@ has the long form):
236
273
  - **Standard redox level** (`{ standard: true }`), thin and dashed: every participant at reference
237
274
  concentration. The redox level sits RT/(zF) ln(a_Ox/a_Red) from it (Nernst, locally).
238
275
  - **Electrode potentials** are gaps to the SHE's standard level V°_e⁻(SHE): E = V_e⁻(electrode) −
239
- V°_e⁻(SHE), E° = V°_e⁻(Ox/Red) − V°_e⁻(SHE), η = E − E_eq. With the data library's μ°, V°_e⁻(SHE)
240
- in water is φ, so `phi: true` draws it. It slopes under current: "vs SHE" needs a place (say,
241
- the stirred bulk).
276
+ V°_e⁻(SHE), E° = V°_e⁻(Ox/Red) − V°_e⁻(SHE), η = E − E_eq. With the data library's μ°,
277
+ V°_e⁻(SHE) in water is φ, so `phi: true` draws it. It slopes under current: "vs SHE" needs a
278
+ place (say, the stirred bulk).
242
279
  - **Species voltage** V_i = μ̄_i/(z_iF) (thick, solid) for every charged species: the Fermi level
243
280
  for e⁻, a quasi-Fermi level for e⁻ and h⁺ in a semiconductor, what a reversible electrode reads
244
- for an ion. A species moves down slopes in its own V_i (drift and diffusion together) and is in
245
- equilibrium where it's flat. A step at a face means resistance there.
281
+ for an ion. It's a display of μ̄_i (the physics, which also covers neutral species): each
282
+ species moves down its own μ̄_i, drift and diffusion together, so its current flows down slopes
283
+ in V_i (cations and holes move downhill, electrons and anions uphill), and it's in equilibrium
284
+ where V_i is flat. A step at a face means resistance there.
246
285
  - **Standard level** V°_i = φ + μ°_i/(z_iF) (thin, solid): a band edge for e⁻ and h⁺; V_i − V°_i =
247
286
  (RT/z_iF) ln(c/c_ref) for ideal species. Steps in V°_i at faces (Donnan, junctions, dipoles) are
248
287
  bookkeeping. In volts the conduction band sits below the valence band.
@@ -261,6 +300,21 @@ has the long form):
261
300
  Maier (Phys. Chem. Chem. Phys. 3, 1668 (2001)) where V_i rails explain impedance or linear
262
301
  response.
263
302
 
303
+ ## Good habits
304
+
305
+ - **Draw the closed-form answer alongside the simulation**, wherever a textbook limit exists
306
+ (Shockley's law, a Nernst potential, a Gaussian pulse, a Warburg arc), as the demos and tests
307
+ do. It checks the setup: a solver's output looks authoritative even when an input is wrong, and
308
+ a mismatch is the cheapest way to catch it. Where the two should differ (high injection, a
309
+ finite pulse), say why.
310
+ - **Stay honestly inside 1D.** driftlet models planar devices. Where the real device isn't one,
311
+ say so and pick the slice that still tells the true story: a MOSFET's gate stack, read as the
312
+ MOS capacitor its channel sits on (pinch-off along the channel is 2D); a BJT along its
313
+ emitter–base–collector axis; one particle of a porous battery electrode, or a thin dense film,
314
+ rather than the whole porous electrode (that's a P2D model).
315
+ - **Check `sol.converged` and `sol.warnings`** on every solve, and pin the CDN version you
316
+ tested against.
317
+
264
318
  ## Common mistakes
265
319
 
266
320
  - **No alignment at a heterointerface.** `interfaces[0] (between … [Si] and … [GaAs]): an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,6 +21,7 @@
21
21
  "types": "./types/index.d.ts",
22
22
  "files": [
23
23
  "src",
24
+ "docs",
24
25
  "types",
25
26
  "llms.txt"
26
27
  ],
package/src/constants.js CHANGED
@@ -33,4 +33,15 @@ export const units = Object.freeze({
33
33
  um: (x) => x * 1e-6,
34
34
  /** cm²/s → m²/s */
35
35
  cm2PerS: (x) => x * 1e-4,
36
+ // Back out of SI, for display.
37
+ /** mol/m³ → mol/L */
38
+ toMolar: (x) => x * 1e-3,
39
+ /** mol/m³ → particles per cm³ */
40
+ toPerCm3: (x) => (x * AVOGADRO) / 1e6,
41
+ /** m → nm */
42
+ toNm: (x) => x * 1e9,
43
+ /** m → µm */
44
+ toUm: (x) => x * 1e6,
45
+ /** m²/s → cm²/s */
46
+ toCm2PerS: (x) => x * 1e4,
36
47
  });
package/src/index.js CHANGED
@@ -146,9 +146,12 @@ export class Device {
146
146
  /**
147
147
  * Adaptive transient to time tEnd (s): variable-step BDF2 with local error control. With
148
148
  * `budgetMs`, it returns after that much wall time even if tEnd isn't reached (`done` says
149
- * which), so an animation can call it once per frame; the step size carries over.
149
+ * which), so an animation can call it once per frame; the step size carries over. `probes`
150
+ * read a species at points inside the device after every accepted step, into `trace.probes`
151
+ * (one array per probe, alongside `trace.t`): what a detector at x sees.
150
152
  * @param {number} tEnd s
151
- * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be' }} [opts]
153
+ * @param {{ tol?: number, dt0?: number, dtMax?: number, budgetMs?: number, maxSteps?: number, method?: 'bdf2' | 'be',
154
+ * probes?: { x: number, species: string, quantity?: 'c' | 'V', region?: string | number }[] }} [opts]
152
155
  * @returns {Solution}
153
156
  */
154
157
  advance(tEnd, opts) {