driftlet 0.1.1 → 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.
package/docs/device.md ADDED
@@ -0,0 +1,529 @@
1
+ # Device reference
2
+
3
+ A device is defined by one plain object (serialisable, so it can be posted to a Web Worker),
4
+ passed to `new Device(def)`. Construction validates everything and throws a `DeviceError`
5
+ naming the offending path (`contacts.left.species.Na+.offset: …`). A field that doesn't belong
6
+ where it's written (a typo, or a value in the wrong object) is an error too, never ignored. Physically meaningful
7
+ choices are never defaulted silently: reference concentrations, standard potentials and
8
+ interface alignments must all be given.
9
+
10
+ Units are SI throughout: m, mol/m³, J/mol, V, A/m², F/m², s. Helpers in `units` convert
11
+ common ones (`units.molar`, `units.perCm3`, `units.eV`, `units.nm`, …). Constants are exported
12
+ too (`FARADAY`, `GAS_CONSTANT`, `EPS0`, …).
13
+
14
+ ## Top level
15
+
16
+ | Key | Meaning |
17
+ |---|---|
18
+ | `T` | temperature, K (default 298.15) |
19
+ | `species` | list of mobile species |
20
+ | `materials` | named materials: bulk properties |
21
+ | `regions` | the device, left to right: each a material plus length, fixed charge, composition |
22
+ | `interfaces` | one entry per face between consecutive regions (optional where defaults apply) |
23
+ | `bulkReactions` | homogeneous reactions |
24
+ | `contacts` | `{ left, right }` |
25
+ | `ports` | internal ports: outside phases exchanging with a window of nodes |
26
+ | `grid` | default spacing for every region |
27
+
28
+ ## Species
29
+
30
+ ```js nocheck
31
+ species: [
32
+ { name: 'e-', z: -1 },
33
+ { name: 'Na+', z: 1, cRef: 1000 }, // optional default reference concentration, mol/m³
34
+ ]
35
+ ```
36
+
37
+ `z` is any integer, including 0 for neutral species. A species can be absent from some
38
+ materials; its outputs are `NaN` there.
39
+
40
+ ## Materials
41
+
42
+ ```js nocheck
43
+ materials: {
44
+ water: {
45
+ epsr: 78.5, // relative permittivity; 0 makes the material strictly neutral
46
+ species: {
47
+ 'Na+': { D: 1.33e-9, mu0: -261.9e3, cRef: 1000 },
48
+ },
49
+ },
50
+ }
51
+ ```
52
+
53
+ Per species present: `D` (m²/s, the coefficient in the flux $`N = -(D c/RT)\nabla\bar\mu`$), `mu0`
54
+ (standard chemical potential, J/mol) and `cRef` (the concentration that `mu0` refers to; falls back
55
+ to the species' `cRef`, and must exist one way or the other). Species not listed are absent.
56
+
57
+ In a material, only *neutral combinations* of standard potentials mean anything physically.
58
+ Shifting every `mu0` by $`z_i F s`$ just moves the material's $`\phi`$, its bookkeeping anchor. See
59
+ [conventions](conventions.md).
60
+
61
+ `epsr: 0` makes a material **strictly neutral**: Poisson is replaced by local neutrality, and $`\phi`$
62
+ there is only a bookkeeping multiplier. Use it for macroscopic systems whose double layers you
63
+ don't want to resolve, such as electrolytes and mixed conductors. (Metals have their own kind of
64
+ material, below.)
65
+
66
+ **Conductors** (metals, and fast ion conductors) are their own kind of material:
67
+
68
+ ```js nocheck
69
+ materials: { Au: { conductor: { species: 'e-', conductivity: 4.1e7 } } } // S/m
70
+ ```
71
+
72
+ A conductor has one mobile carrier, and its only unknown is that carrier's $`\bar\mu`$: for a metal,
73
+ the Fermi level. There's no $`\varepsilon`$, no `mu0` or `cRef`, and $`\phi`$ is undefined inside. Its
74
+ bulk is neutral and incompressible, conduction is ohmic ($`J = -\sigma\nabla V`$), and any charge it
75
+ holds sits as a sheet at a charged face. Since nothing is stored inside, the carrier's level is
76
+ exactly linear across a conductor region, so the region is a single grid cell, whatever its length.
77
+ Conductor regions take no `fixedCharge`, `c0`, `grid`, `velocity` or `mixing`. A port on a conductor
78
+ attaches to all of it (see [ports](#internal-ports)). See
79
+ [Faces next to a conductor](#faces-next-to-a-conductor).
80
+
81
+ `statistics` (optional) lists non-ideal statistics models, each covering named species:
82
+ Fermi–Dirac, lattice gas (crowding), Redlich–Kister, Debye–Hückel, insertion hosts (OCV
83
+ curves) and custom functions. Species not listed are ideal. See [statistics](statistics.md).
84
+
85
+ ```js nocheck
86
+ statistics: [{ type: 'lattice', species: ['Na+', 'Cl-'], cMax: 5000 }]
87
+ ```
88
+
89
+ ## Regions
90
+
91
+ ```js nocheck
92
+ regions: [
93
+ { name: 'n', material: 'Si', length: 2e-6, fixedCharge: 16.6 * FARADAY }, // C/m³
94
+ { material: 'water', length: 1e-6, c0: { 'Na+': 10, 'Cl-': 10 } },
95
+ ]
96
+ ```
97
+
98
+ - `fixedCharge`: immobile charge density (doping, ionomer), C/m³. Default 0. In an insertion
99
+ host it's balanced by background electronic carriers.
100
+ - `c0`: initial concentrations, mol/m³. Species connected to a contact start from that contact's
101
+ level and don't need it. Any other species (blocked everywhere, or only made and consumed by
102
+ reactions) does: its `c0` fixes the amount it conserves. Concentrations are positive ($`\bar\mu`$ is
103
+ logarithmic in them); to have none of a species in a region, leave it out of that region's
104
+ material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero. Where nothing else sets
105
+ $`\phi`$ at the start, a region beside an electrode starts with the electrode's first reaction at
106
+ equilibrium (the electrode at its open-circuit level); otherwise $`\phi`$ carries over from the
107
+ left.
108
+ - `velocity`: imposed flow toward +x, m/s (default 0). It carries every mobile species
109
+ ($`D > 0`$) along with the fluid. It's uniform within the region: in strict 1D, incompressible
110
+ flow is the same everywhere. A step in velocity at a face means solvent enters or leaves
111
+ there sideways *without* its solutes, which still cross the face by flux continuity: an ideal
112
+ ultrafiltration membrane, where solutes pile up (concentration polarisation). It isn't a
113
+ sideways inflow or outflow of solution.
114
+ - `mixing`: eddy (turbulent) mixing diffusivity $`D_{\mathrm{mix}}`$, m²/s (default 0). It mixes
115
+ composition without carrying current, and does nothing at equilibrium (see
116
+ [conventions](conventions.md#transport)). A strongly mixed region is the local stand-in for a
117
+ well-stirred bath.
118
+ - `grid`: per-region override of the grid options (below).
119
+
120
+ ## Interfaces
121
+
122
+ One entry per face, `interfaces[f]` sitting between `regions[f]` and `regions[f + 1]`. All
123
+ fields are optional where the defaults apply.
124
+
125
+ ```js nocheck
126
+ interfaces: [
127
+ {
128
+ step: { species: 'e-', value: units.eV(0.25) }, // alignment: or { dipole: volts }
129
+ phi: 'pinned', // 'pinned' | 'neutral' | { type: 'capacitive', C }
130
+ sheetCharge: 0, // C/m²
131
+ species: { 'Cl-': 'blocked', 'Li+': { type: 'conductance', G: 50 }, 'Na+': 'blocked' },
132
+ reactions: [{ left: { 'Na+': -1 }, right: { 'Na+': 1 }, k0: 1e-3, alpha: 0.5 }], // Na⁺ crosses only by this
133
+ },
134
+ ]
135
+ ```
136
+
137
+ **Alignment** (exactly one, for a face between *different* materials under a `pinned` or
138
+ `capacitive` law):
139
+ - `step: { species, value }`: the step in that charged species' standard level across the face,
140
+ right minus left, $`(\mu^\circ_R + zF\phi_R) - (\mu^\circ_L + zF\phi_L)`$, in J/mol. For electrons
141
+ that's the conduction-band offset.
142
+ - `dipole`: the $`\phi`$ jump, right minus left, in each material's own anchoring, in volts.
143
+ There is no default: omitting it is an error. When vacuum-level estimates are all you have,
144
+ `vacuumDipole` from `driftlet/kit` turns them into a `dipole` (see the
145
+ [alignment guide](alignment.md)). A face between regions of the same material
146
+ defaults to no dipole.
147
+
148
+ **Electrostatic law** `phi`:
149
+ - `'pinned'`: $`\phi`$ jumps by the alignment. The default, and exact when the grid resolves the
150
+ double layers on both sides. Not between two $`\varepsilon = 0`$ materials: with no field on either side,
151
+ nothing would determine the face's charge.
152
+ - `'neutral'`: no charge at the face and a free jump, set by neutrality on each side (Donnan).
153
+ The alignment drops out and must not be given. This is the default between two $`\varepsilon = 0`$
154
+ materials, and next to an insertion host (where $`\phi`$ is undefined).
155
+ - `{ type: 'capacitive', C }`: a Helmholtz layer, $`D = -C \cdot (\Delta\phi - \mathtt{dipole})`$, C
156
+ in F/m².
157
+
158
+ **Species laws** `species` (default: local equilibrium, $`\bar\mu`$ continuous, where the species is
159
+ present on both sides; blocked otherwise): `'equilibrium'`, `'blocked'`, or
160
+ `{ type: 'conductance', G }` ($`J = G \cdot (V_L - V_R)`$, G in S/m², charged species). A species that
161
+ takes part in a reaction at the face and exists on both sides has no default: give its link
162
+ (`'blocked'` if it crosses only through the reaction), since free crossing alongside would
163
+ short-circuit the kinetics.
164
+
165
+ **Reactions** `reactions` at the face, each written as an equation or as its participants on
166
+ each side, with signed stoichiometric coefficients ($`\nu < 0`$ consumed, $`\nu > 0`$ produced by the forward
167
+ reaction):
168
+
169
+ ```js nocheck
170
+ { equation: 'Fe3+ + e- = Fe2+', k0: 1e-4, alpha: 0.5 } // a metal on one side
171
+ { equation: 'Ag+ + e- = Ag(s)', fixed: { 'Ag(s)': 0 }, k0: 1e-3 } // Ag⁺ + e⁻ ⇌ Ag(s)
172
+ { equation: 'Li+(left) = Li+(right)', k0: 1e-3 } // Li⁺ transfer
173
+ { left: { 'e-': -1 }, right: { 'Fe3+': -1, 'Fe2+': 1 }, k0: 1e-4, alpha: 0.5 } // by sides
174
+ ```
175
+
176
+ In an equation, terms are separated by ` + ` (with spaces), sides by `=`, a coefficient is an
177
+ integer and a space (`2 e-`), and an empty side is `0`. Each participant goes to the side whose
178
+ material holds it (on a conductor's side, only its carrier); a species held on both sides is
179
+ labelled with its side, `'Li+(left)'`, or its material, `'Li+(graphite)'`.
180
+
181
+ For example, an intercalation electrode taking Li⁺ from the electrolyte through a
182
+ desolvation (or SEI) step has Li⁺ on both sides, with `species: { 'Li+': 'blocked' }` and the
183
+ transfer reaction above.
184
+
185
+ A participant is a species present on its side (on a conductor's side, only its carrier) or a
186
+ fixed-activity neutral, given by its $`\mu`$ in `fixed` (J/mol; the side doesn't matter). Charge must
187
+ balance. The rate per area, with $`a = A/RT = -\sum \nu\bar\mu/RT`$ over all participants, is
188
+ Butler–Volmer,
189
+
190
+ ```math
191
+ r = k_0 \prod_{\nu<0} (c/c_{\mathrm{ref}})^{|\nu|(1-\alpha)} \prod_{\nu>0} (c/c_{\mathrm{ref}})^{\nu\alpha} \left(e^{\alpha a} - e^{-(1-\alpha)a}\right)
192
+ ```
193
+
194
+ with $`k_0`$ in mol/(m²·s) and $`\alpha`$ (default 0.5) between 0 and 1. A conductor's carrier has
195
+ activity 1 and no factor. That's mass action with rate constants that depend on the electrical part
196
+ of the affinity: exactly zero at $`A = 0`$, whatever $`k_0`$ and $`\alpha`$. Each participant is made or
197
+ consumed at its side's edge node, just behind any Stern layer, so Frumkin effects arise by
198
+ themselves. A face can carry several reactions; a conductor coupled to two couples settles at their
199
+ mixed potential. Each solution reports the rates as `interfaces[f].rates`.
200
+
201
+ ### Faces next to a conductor
202
+
203
+ A conductor has no $`\phi`$, so a face beside it can't take a `dipole` or `step` alignment. Its $`\phi`$
204
+ law is:
205
+
206
+ - `'neutral'` (no charge at the face): the default next to an $`\varepsilon = 0`$ material, and the only
207
+ choice between two conductors; or
208
+ - `{ type: 'capacitive', C }` with `zeroCharge`: the other side's $`\phi`$ is tied to the conductor's
209
+ level $`V_F`$ (the carrier's $`\bar\mu`$ as a voltage), with displacement
210
+ $`C \cdot (V_F - \mathtt{zeroCharge} - \phi_{\mathrm{edge}})`$ toward the other side, which is the
211
+ conductor's surface charge. `zeroCharge` is $`V_F - \phi_{\mathrm{edge}}`$ at zero charge
212
+ (`vacuumZeroCharge` in `driftlet/kit` estimates it from a work function).
213
+
214
+ A `pinned` law isn't offered here: an internal conductor holds its surface charge in a
215
+ finite capacitance (a large C approaches the pinned limit). A pinned barrier is still available
216
+ with the metal as a contact.
217
+
218
+ The conductor's carrier can continue across as a species (e⁻ into a semiconductor,
219
+ `'equilibrium'` by default). An electrode is a conductor region with `reactions` at its face,
220
+ the metal's electrons taking part on its side at its Fermi level, behind a contact that holds
221
+ the electrons (`{ V, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'bulk' }`). A
222
+ floating conductor region with reactions on both faces is a bipolar electrode.
223
+
224
+ ## Bulk reactions
225
+
226
+ ```js nocheck
227
+ bulkReactions: [
228
+ { equation: 'e- + h+ = 0', kf: { Si: 2e8 } },
229
+ { equation: 'H+ + OH- = H2O', fixed: { H2O: -237.13e3 }, kf: { water: 1.4e8 } },
230
+ { nu: { 'H+': -1, 'OH-': -1, H2O: 1 }, fixed: { H2O: -237.13e3 }, kf: { water: 1.4e8 } }, // the same, as nu
231
+ ]
232
+ ```
233
+
234
+ `equation`, or `nu` with signed stoichiometric coefficients ($`\nu < 0`$ consumed, $`\nu > 0`$ produced by
235
+ the forward reaction); a participant on both sides of an equation is an error (give the net
236
+ reaction). Participants that aren't species are fixed-activity neutrals, given by their $`\mu`$ in
237
+ `fixed` (J/mol). Charge must balance. The rate is mass action,
238
+ $`r = k_f \prod_{\nu<0} c^{|\nu|} (1 - e^{-A/RT})`$ with $`A = -\sum \nu\bar\mu`$ the affinity. That's
239
+ $`k_f \prod c_R - k_b \prod c_P`$, with $`k_b`$ fixed by the standard potentials, so equilibrium is exactly
240
+ $`A = 0`$. `kf` maps material
241
+ names to forward rate constants (units making r mol/(m³·s)), and the reaction runs only in
242
+ those materials.
243
+
244
+ **Generation** is a reaction from a reservoir: photogeneration is
245
+ $`\text{photon} \to \mathrm{e}^- + \mathrm{h}^+`$, with the photons a fixed participant whose $`\mu`$
246
+ sits well above the gap, so that $`e^{-A/RT}`$ is negligible and the rate is `kf` itself, the
247
+ generation rate (mol/(m³·s)). It's uniform within each material listed in `kf` (there's no optical
248
+ absorption profile). The photons' $`\mu`$ is the honest part: light is a reservoir far from the
249
+ device's temperature, and the rate still vanishes at $`A = 0`$.
250
+
251
+ ```js nocheck
252
+ bulkReactions: [{ equation: 'photon = e- + h+', fixed: { photon: units.eV(3) }, kf: { Si: 0.1 } }]
253
+ ```
254
+
255
+ ## Contacts
256
+
257
+ A contact is an interface whose far side is an **outside phase with known levels**: think of it as
258
+ one more region whose node is fully known. The outside phase's levels form a rigid ladder,
259
+ $`V_i = V + \mathtt{offset}_i`$ for charged species ($`\bar\mu`$ given directly for neutral ones). The
260
+ offsets are the outside phase's own chemistry, and the external circuit slides the whole ladder by
261
+ the terminal voltage V: held, or floating under a current (see [terminals](#terminals)). The laws
262
+ joining it to the device are the same as at internal interfaces.
263
+
264
+ ```js nocheck
265
+ contacts: {
266
+ left: {
267
+ V: 0, // terminal voltage, V
268
+ terminal: 'e-', // the species whose voltage V is
269
+ species: { 'e-': 'equilibrium', 'h+': { type: 'equilibrium', offset: 0 }, 'Cl-': 'blocked' },
270
+ phi: 'bulk', // 'bulk' | 'neutral' | 'pinned' | { type: 'capacitive', C }, with zeroCharge beside the last two
271
+ },
272
+ right: { bath: { c: { 'Na+': 10, 'Cl-': 10 }, reference: 'Cl-' } },
273
+ }
274
+ ```
275
+
276
+ **Species laws** (default `'blocked'`):
277
+ - `'equilibrium'`: the species is in equilibrium with the outside phase, so it's held at
278
+ $`V_i = V + \mathtt{offset}`$ (charged) or $`\bar\mu = \mathtt{mu}`$ (neutral, J/mol).
279
+ - The offset defaults to 0 only for the terminal species. Any other species needs one,
280
+ because it's a property of the outside phase: the chemical potential, per charge, of the
281
+ neutral combination the species forms with the terminal species there.
282
+ - For an ion at a reversible electrode,
283
+ $`\mathrm{M}^{n+} + n\,\mathrm{e}^- \rightleftharpoons \mathrm{M(s)}`$ gives
284
+ $`\mathtt{offset} = \mu_{\mathrm{M}}/(nF)`$, the metal's own chemical potential per charge, which
285
+ is 0 on the usual table convention. ($`E^\circ`$ isn't the offset: it's already carried by the
286
+ ion's $`\mu^\circ`$ in the solution.)
287
+ - At a metal, holes and electrons share the metal's voltage: `offset: 0`.
288
+ - `{ type: 'conductance', G, offset }` (charged species): ohmic exchange with the outside level
289
+ at $`V + \mathtt{offset}`$, $`J = G \cdot (V_{\mathrm{out}} - V_i)`$, G in S/m².
290
+ - `{ type: 'exchange', k, mu }` (neutral species):
291
+ $`N_{\mathrm{in}} = k \cdot (\mu_{\mathrm{out}} - \mu)/RT`$, k in mol/(m²·s).
292
+ - `'blocked'`: no flux.
293
+
294
+ **`bath`** (instead of `species` and `phi`): the outside phase is a neutral composition `c`
295
+ held in place by a charged `reference` species (as for a reversible reference electrode, e.g.
296
+ Cl⁻ for Ag/AgCl), which is the terminal. Every bath species is in equilibrium at the level its
297
+ composition implies, and the $`\phi`$ law is `'bulk'`. An optional `offset` places the reference
298
+ species relative to V.
299
+
300
+ **`phi`**, required whenever any species or reaction connects at the contact (default
301
+ `'neutral'`):
302
+ - `'bulk'`: the end node is plain bulk, locally neutral, with no double layer at the contact
303
+ (ohmic contacts, baths). The outside takes whatever surface charge that needs.
304
+ - `'neutral'`: no charge at the face ($`D = 0`$), as for an internal `'neutral'` face.
305
+ - `{ type: 'capacitive', C }` with the contact's `zeroCharge`: a gate or Stern layer to a conductor
306
+ at the terminal voltage V. The displacement into the device is
307
+ $`C \cdot ((V - \mathtt{zeroCharge}) - \phi_{\mathrm{edge}})`$, so `zeroCharge` is the value of
308
+ $`V - \phi_{\mathrm{edge}}`$ at which the interface carries no charge: the potential of zero charge
309
+ (pzc) of an electrode, or for a gate the flat-band voltage less the semiconductor's bulk $`\phi`$
310
+ ($`V_{\mathrm{FB}} = \mathtt{zeroCharge} + \phi_{\mathrm{bulk}}`$).
311
+ - `'pinned'` with `zeroCharge`: the $`C \to \infty`$ limit,
312
+ $`\phi_{\mathrm{edge}} = V - \mathtt{zeroCharge}`$. This is what a "fixed $`\phi`$" boundary honestly
313
+ means. For example, a Schottky barrier $`\phi_B`$ on n-type material with
314
+ $`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`.
315
+
316
+ Like every alignment, `zeroCharge` is a property of that interface. To estimate it from vacuum
317
+ levels, `vacuumZeroCharge(def, W, inside)` from `driftlet/kit` takes the work function W of the
318
+ conductor at the terminal voltage and the inside material's anchor and offset (see the
319
+ [alignment guide](alignment.md)).
320
+
321
+ Contacts carry no reactions. An electrode reaction belongs at the face of a conductor region,
322
+ with the contact behind it holding the conductor's electrons (see
323
+ [faces next to a conductor](#faces-next-to-a-conductor)).
324
+
325
+ ## Internal ports
326
+
327
+ A port is an outside phase with known levels, like a contact's, attached to a window of nodes
328
+ inside one region instead of at an end. It's the 1D stand-in for whatever feeds or drains a
329
+ species sideways: source and drain grounding a MOS channel, salt injected mid-solution, a
330
+ reference electrode's reservoir.
331
+
332
+ ```js nocheck
333
+ ports: [{
334
+ name: 'channel',
335
+ region: 'Si', // name or index
336
+ from: 495e-9, to: 500e-9, // window, m from the region's left end (default: the whole region)
337
+ V: 0, // the port's terminal voltage (or I, or V and R: see terminals)
338
+ terminal: 'e-',
339
+ species: {
340
+ 'e-': 'equilibrium', // μ̄ held at V_i = V + offset throughout the window
341
+ 'Na+': { type: 'conductance', G: 1e9, offset: 0.2 }, // source G (V_out − V_i)/(zF) per volume, G in S/m³
342
+ O2: { type: 'exchange', k: 1e-3, mu: -2e3 }, // neutral: source k (μ_out − μ)/RT per volume, k in mol/(m³·s)
343
+ },
344
+ }]
345
+ ```
346
+
347
+ Links and offsets are those of contacts, per volume instead of per area: 0 by default only for
348
+ the terminal species, and an absolute `mu` for neutral species. A port is a terminal like a
349
+ contact: held at a voltage, behind a resistance, or driven by a current (a reference electrode
350
+ is a port at `I: 0`). Each solution reports `ports[k]`, with `{ name, V, flux, current }`: what
351
+ the port brings into the device.
352
+
353
+ A held (`'equilibrium'`) level leaves the device's two end nodes to their contacts.
354
+
355
+ A port driven by a current (`I`, or a waveform) floats to whatever voltage delivers it: with a
356
+ conductance `G` per volume over a window of width $`w`$, it sits about $`I/(G w)`$ above the
357
+ window's level for that species. So `G` decides how the current is shared across the window, not
358
+ how much enters. A small `G`, with the port volts above the window, spreads it evenly, like a
359
+ current source; a large one pins the window near the port's level, and the current enters
360
+ wherever the species is drawn away fastest. At `I: 0` the port still ties that species' level
361
+ together across its window (a large `G` shorts the window for it), so keep such windows narrow.
362
+
363
+ On a [conductor region](#materials), a port is a wire to the whole conductor: it takes no window,
364
+ only the conductor's carrier, and a conductance link's `G` is per area (S/m², a resistance
365
+ $`R \cdot A`$ to the port's voltage as $`G = 1/(R \cdot A)`$). That's how a floating electrode is tied
366
+ to ground through a resistor.
367
+
368
+ For example, in a 1D MOS capacitor without generation, inversion electrons can only arrive by
369
+ minority-carrier diffusion from the back contact, which can take weeks. The inversion layer is
370
+ then effectively floating, and its Fermi level is undetermined to within round-off, so no
371
+ steady state can be computed there. A port holding the electrons beside the oxide at the
372
+ channel's potential anchors it, as source and drain would, and the device then shows the
373
+ low-frequency C–V.
374
+
375
+ ## Terminals
376
+
377
+ The two contacts and every port are the device's terminals, named `left`, `right` and by each
378
+ port's `name`. Each is driven on its own:
379
+
380
+ ```js nocheck
381
+ contacts: {
382
+ left: { V: 0, ... }, // held at a voltage
383
+ right: { I: -2, ... }, // driven by a current into the device, A/m² (I: 0 is open circuit)
384
+ },
385
+ ports: [{ name: 'ref', I: 0, ... }, // a reference electrode: no current, its voltage read off
386
+ { name: 'wire', V: 0, R: 1e-3, ... }], // a source V behind a series resistance R (Ω·m²)
387
+ ```
388
+
389
+ - **`V`**: the terminal's voltage, the shift of its outside phase's ladder, as the voltage of
390
+ its `terminal` species (a gate's is its own metal's electrons). Neither `V` nor `I` given
391
+ means `V: 0`.
392
+ - **`I`**: the current into the device through this terminal (conduction plus displacement);
393
+ the voltage floats and is solved for.
394
+ - **`V` and `R`**: a source behind a resistance, $`I = (V - V_{\mathrm{terminal}})/R`$.
395
+
396
+ At least one terminal must be held at a voltage, or the device's overall level floats. In
397
+ steady state the terminal currents sum to zero. A contact can be driven by a current only if
398
+ something passes it (a linked species, or a gate's displacement).
399
+
400
+ **Waveforms.** `V` and `I` can be piecewise linear in time:
401
+ `{ t: [0, 1, 2], values: [0, 0.5, 0], repeat: true }` (s and V, or s and A/m²), constant beyond the
402
+ points, or periodic with period $`t_{\mathrm{last}} - t_0`$ when `repeat` is set: a triangle wave is a
403
+ cyclic voltammogram. A **step** is two points at one time,
404
+ `{ t: [0, 1e-3, 1e-3], values: [0, 0, 0.5] }`, and a repeating waveform whose last value differs
405
+ from its first jumps back each period (a sawtooth); a square wave is steps both ways. Write a jump
406
+ as a step rather than as a very fast ramp, which the time-step control has to resolve. A time step
407
+ ending at t sees the sources as they are just before t (so it ends on a jump's near side, and the
408
+ next step carries the jump); `solve()` and `impedance()` use the values at the present time, after
409
+ any jump there. `advance()` lands on every breakpoint and restarts its time stepping there. A
410
+ device's time starts at 0 when it's made, and waveforms are read against it: `solve()` doesn't
411
+ advance it, `advance()` and `step()` do.
412
+
413
+ ## Grid
414
+
415
+ `{ hmin, hmax, ratio = 1.2, minCells = 8 }`, per device, overridable per region. With no grid
416
+ options at all, each region is graded from `hmin` = 1/1000 to `hmax` = 1/20 of its length. Cells grow
417
+ geometrically from `hmin` at both ends of each region up to `hmax`, scaled to fit exactly. Each
418
+ region boundary becomes a pair of nodes at the same x, one per side. The grid is never refined
419
+ automatically. If a double layer that the model resolves is coarser than the local Debye
420
+ length, the solution's `warnings` say so.
421
+
422
+ Grade the grid toward wherever a profile is steep: double layers, and electrodes where a species
423
+ is depleted. Near a limiting current the depleted species' profile is steep in a thin layer at
424
+ the electrode, and a uniform grid there overshoots: a 100 µm silver nitrate cell on 100 uniform
425
+ cells exceeds the limiting current by 1.4% at 0.5 V, while `{ hmin: 10e-9, hmax: 2e-6 }` stays
426
+ within 0.1% with as many nodes. Where a species carrying the current is steep across a region's
427
+ end cells in this way, the solution's `warnings` say so, with a rough estimate of the excess.
428
+
429
+ ## Using a device
430
+
431
+ ```js nocheck
432
+ const dev = new Device(def);
433
+ const sol = dev.solve(); // steady state (or equilibrium)
434
+ dev.set({ contacts: { right: { V: 0.3 } } }); // deep-merged change; the state is kept as a warm start
435
+ const sol2 = dev.solve();
436
+ const tr = dev.step(1e-6); // one backward-Euler step (auto-subdivided if needed)
437
+ const tr2 = dev.step(1e-6, { method: 'bdf2' }); // second-order BDF2 once a previous step exists
438
+ const run = dev.advance(1e-3, { tol: 1e-3 }); // adaptive steps to t = 1 ms
439
+ const frame = dev.advance(t1, { budgetMs: 8 }); // …or as far as 8 ms of compute allows
440
+ const Z = dev.impedance([1, 10, 100, 1e3]); // small-signal impedance about the steady state
441
+ const now = dev.solution(); // snapshot of the current state
442
+ ```
443
+
444
+ - `solve()` finds the steady state from the current state without advancing time. If every
445
+ species is fed by a contact, it solves the steady equations directly. Otherwise conserved
446
+ amounts (blocked species, reactive moieties) are kept exactly. If a direct solve fails at a
447
+ bias (a cold start far from equilibrium), it solves with both terminals level and ramps the
448
+ right terminal's voltage to its target; with generation reactions (below), it ramps their
449
+ rates up from nearly nothing.
450
+ - `step(dt, { method })` advances the transient by dt seconds, halving internally where Newton
451
+ needs it. `method` is `'be'` (backward Euler, the default) or `'bdf2'`.
452
+ - `advance(tEnd, opts)` integrates adaptively to `tEnd` with variable-step BDF2, controlling
453
+ the local error per step to `tol` (default 1e-3) in thermal units of every potential ($`\phi`$ and
454
+ each $`\bar\mu/RT`$): roughly 0.1% in concentrations. It lands exactly on `tEnd`. Options: `tol`,
455
+ `dt0` (first step), `dtMax`, `budgetMs` (return after this much wall time, with
456
+ `done: false`), `maxSteps`, `method`. The step size carries over between calls, so an
457
+ animation can call `advance(tNext, { budgetMs })` once per frame. The solution adds `done`,
458
+ `rejected`, and a `trace` of terminal current and voltage after every accepted step. With
459
+ `probes: [{ x, species, quantity, region }]` the trace also reads a species inside the device,
460
+ as `trace.probes[k]` beside `trace.t`: its concentration (`quantity: 'c'`, the default, mol/m³)
461
+ or species voltage (`'V'`), linearly between the nodes around `x` (at an interface, `region`
462
+ picks the side). That's what a detector at `x` sees. Probes read at accepted steps, which grow
463
+ as a transient slows, so give `dtMax` to resolve a signal in time.
464
+ - `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
465
+ it: $`Z(f) = \delta V/\delta I`$ in Ω·m² at one terminal (`'right'` by default), with I into the
466
+ device. A held terminal's voltage is perturbed, or a driven one's current; the other terminals
467
+ keep their drives (held ones at AC ground, driven ones open). A terminal behind a resistance can't
468
+ be the one measured: the resistance belongs to the external circuit. With `profiles: true`, each
469
+ frequency also returns complex profiles of $`\delta\phi`$, $`\delta\bar\mu`$ and $`\delta c`$ per unit
470
+ excitation.
471
+ - `set(patch)` merges plain objects deeply (arrays are replaced). A patch that changes only the
472
+ terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
473
+ Otherwise the device is rebuilt: the current state carries over while the grid and species
474
+ are unchanged, or restarts from the regions' `c0`. A carried state keeps what it holds: a
475
+ stretch that was closed stays at its amount, and one that a change closes (or splits) keeps
476
+ what it holds at that moment. So a new `c0` doesn't apply to a carried state; to start over
477
+ from `c0`, make a new `Device`. Either way the time stepping restarts its
478
+ order, as after any discontinuity.
479
+
480
+ For example, a silver nitrate cell between silver electrodes: its impedance spectrum, then the
481
+ current transient after a voltage step.
482
+
483
+ ```js
484
+ import { Device } from 'driftlet';
485
+
486
+ const electrode = (V) => ({ V, terminal: 'Ag+', species: { 'Ag+': 'equilibrium', 'NO3-': 'blocked' }, phi: 'bulk' });
487
+ const dev = new Device({
488
+ species: [
489
+ { name: 'Ag+', z: 1, cRef: 1000 },
490
+ { name: 'NO3-', z: -1, cRef: 1000 },
491
+ ],
492
+ materials: { water: { epsr: 0, species: { 'Ag+': { D: 1.65e-9, mu0: 77.1e3 }, 'NO3-': { D: 1.9e-9, mu0: -111.3e3 } } } },
493
+ regions: [{ material: 'water', length: 20e-6, c0: { 'NO3-': 10 } }],
494
+ contacts: { left: electrode(0), right: electrode(0) },
495
+ grid: { hmin: 10e-9, hmax: 0.5e-6 }, // graded: fine at the electrodes, where Ag⁺ depletes
496
+ });
497
+
498
+ const { f, Z } = dev.impedance([0.01, 1, 100, 1e4]);
499
+ f.forEach((fk, k) => console.log(`${fk} Hz: Z = ${Z.re[k].toExponential(3)} ${Z.im[k].toExponential(3)}i Ω·m²`));
500
+
501
+ dev.set({ contacts: { right: { V: 0.05 } } });
502
+ const run = dev.advance(1, { tol: 1e-3 }); // one second, adaptively
503
+ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.findIndex((t) => t >= 0.01)].toFixed(1)} A/m², I(1 s) = ${run.current.toFixed(2)} A/m²`);
504
+ ```
505
+
506
+ ## Solutions
507
+
508
+ | Field | Contents |
509
+ |---|---|
510
+ | `x`, `region` | node positions and region index; each interface position appears twice (one node per side) |
511
+ | `species`, `regions` | `{ name, z }` for each species; `{ name, material, x0, x1 }` for each region |
512
+ | `phi` | bookkeeping $`\phi`$, V (`NaN` where undefined) |
513
+ | `c[name]`, `mu[name]`, `muStd[name]` | concentration, $`\bar\mu`$, standard level $`\mu^\circ + zF\phi`$ (`NaN` where absent) |
514
+ | `V[name]`, `Vstd[name]` | species voltage $`\bar\mu/(zF)`$ and standard level as a voltage (charged species) |
515
+ | `current`, `terminalVoltage` | current toward +x through the device (A/m²) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
516
+ | `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
517
+ | `contacts.left/right` | `{ V, flux: {name}, D, current }` at each contact |
518
+ | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
519
+ | `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device |
520
+ | `interfaces[f]` | `{ dipole, sheetCharge, D, N: {name}, rates }`: what crosses each face by its links, and each face reaction's rate (mol/(m²·s)) |
521
+ | `charge` | total charge in the device, C/m² |
522
+ | `conservation` | per species stretch: amount, reference, intake through contacts, drift |
523
+ | `warnings` | e.g. unresolved double layers, conventions a statistics model relies on, and for a failed solve, where the system is nearly singular |
524
+ | `converged`, `iterations`, `steps`, `substeps`, `history`, `time` | solver bookkeeping |
525
+ | `done`, `rejected`, `trace` | from `advance()`: whether `tEnd` was reached; rejected steps; `{ t, current, voltage }` per accepted step |
526
+
527
+ A stretch is a run of regions in which a species is present and connected. Its `drift`
528
+ compares its amount with the reference amount plus everything that came in through the
529
+ contacts. It's `NaN` where a reaction also makes or consumes the species.