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