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/README.md +8 -2
- package/docs/alignment.md +146 -0
- package/docs/conventions.md +194 -0
- package/docs/data.md +120 -0
- package/docs/device.md +529 -0
- package/docs/kit.md +299 -0
- package/docs/logo-dark.svg +7 -0
- package/docs/logo.svg +7 -0
- package/docs/numerics.md +629 -0
- package/docs/statistics.md +226 -0
- package/docs/visualization.md +259 -0
- package/llms.txt +62 -9
- package/package.json +2 -1
- package/src/constants.js +11 -0
- package/src/index.js +5 -2
- package/src/kit.js +1 -0
- package/src/solver.js +66 -7
- package/src/sources.js +103 -0
- package/src/types.js +3 -2
- package/types/constants.d.ts +10 -0
- package/types/index.d.ts +11 -2
- package/types/kit.d.ts +1 -0
- package/types/solver.d.ts +6 -0
- package/types/sources.d.ts +102 -0
- package/types/types.d.ts +4 -2
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.
|