driftlet 1.3.0 → 1.3.3
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 +1 -1
- package/docs/device.md +34 -9
- package/docs/kit.md +7 -3
- package/docs/numerics.md +21 -4
- package/docs/reliability.md +18 -13
- package/docs/statistics.md +18 -1
- package/llms.txt +13 -9
- package/package.json +1 -1
- package/src/checks.js +9 -1
- package/src/describe.js +2 -0
- package/src/device.js +16 -0
- package/src/impedance.js +146 -44
- package/src/index.js +1 -0
- package/src/kit.js +6 -2
- package/src/newton.js +23 -5
- package/src/solution.js +12 -1
- package/src/solver.js +12 -3
- package/src/start.js +129 -68
- package/src/steady.js +92 -9
- package/src/timeSteps.js +13 -1
- package/types/impedance.d.ts +2 -2
- package/types/solver.d.ts +2 -0
- package/types/start.d.ts +8 -0
- package/types/steady.d.ts +4 -1
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
|
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
Or straight from a CDN in a page, pinned to a version:
|
|
59
|
-
`https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
59
|
+
`https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js` (and `src/kit.js`, `src/plot.js`).
|
|
60
60
|
TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
|
|
61
61
|
[`llms.txt`](llms.txt).
|
|
62
62
|
|
package/docs/device.md
CHANGED
|
@@ -112,9 +112,11 @@ regions: [
|
|
|
112
112
|
material. In a strictly neutral region, $`\sum z \cdot c_0`$ must be zero.
|
|
113
113
|
Where two strictly neutral regions meet at a face that holds ions in equilibrium and their
|
|
114
114
|
`c0` leaves it out of equilibrium (a different fixed charge or immobile ion on each side), the
|
|
115
|
-
two nodes at the face start in it, as the face reaches it at $`t = 0^+`$: the ions
|
|
116
|
-
shared between them so their $`\bar\mu`$ is equal across it and each side neutral,
|
|
117
|
-
one's amount over the two nodes kept, everything else as `c0` has it.
|
|
115
|
+
two nodes at the face start a transient in it, as the face reaches it at $`t = 0^+`$: the ions
|
|
116
|
+
it passes shared between them so their $`\bar\mu`$ is equal across it and each side neutral,
|
|
117
|
+
each one's amount over the two nodes kept, everything else as `c0` has it. (The first time
|
|
118
|
+
step from a cold start does this; a steady solve starts from `c0` as given, which it reaches
|
|
119
|
+
better from.)
|
|
118
120
|
An entry can instead be a profile, `{ x: [...], values: [...] }`, against the device's x (m, not
|
|
119
121
|
the region's own), piecewise linear between its points and constant beyond its ends, so a
|
|
120
122
|
transient can start from a state it didn't simulate reaching: a packet already injected, a
|
|
@@ -539,7 +541,18 @@ levels are that material's, and the end node takes them through a Donnan step.
|
|
|
539
541
|
- `'pinned'` with `zeroCharge`: the $`C \to \infty`$ limit,
|
|
540
542
|
$`\phi_{\mathrm{edge}} = V - \mathtt{zeroCharge}`$. This is what a "fixed $`\phi`$" boundary honestly
|
|
541
543
|
means. For example, a Schottky barrier $`\phi_B`$ on n-type material with
|
|
542
|
-
$`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`.
|
|
544
|
+
$`\mu^\circ_{\mathrm{e}^-} = 0`$ is `zeroCharge: φ_B`. With a carrier linked in equilibrium, a
|
|
545
|
+
pinned contact sets that carrier's density at the edge,
|
|
546
|
+
$`c_{\mathrm{edge}} = c_{\mathrm{ref}} \exp\big((z F\,\mathtt{zeroCharge} - \mu^\circ)/RT\big)`$, so a contact that injects holes at
|
|
547
|
+
$`N_v`$ (their `cRef`, $`\mu^\circ = 0`$) is `zeroCharge: 0`, and a 0.5 eV barrier for them is
|
|
548
|
+
`zeroCharge: -0.5`. This is the contact for a layer of one carrier with nothing to neutralise it
|
|
549
|
+
(a hole-only organic diode): `ohmic()`'s `'bulk'` law, local neutrality, can't hold there.
|
|
550
|
+
|
|
551
|
+
An ideally blocking electrode (inert: no reactions, nothing crossing) is a contact with no
|
|
552
|
+
terminal species and no links, `{ V, phi: 'pinned', zeroCharge }`: the solution's diffuse layer
|
|
553
|
+
is its whole double layer (the validation suite's Macdonald impedance is this cell), and a Stern layer in
|
|
554
|
+
series is `phi: { type: 'capacitive', C }` in place of `'pinned'`. (A metal layer behind a
|
|
555
|
+
capacitive face of large C does the same at more cost, and its C is warned of as a unit slip.)
|
|
543
556
|
|
|
544
557
|
A Schottky contact is that pinned barrier with its carriers passing at their emission velocities:
|
|
545
558
|
`{ V, terminal: 'e-', species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v: vp, offset: 0 } }, phi: 'pinned', zeroCharge: φ_B }`,
|
|
@@ -753,8 +766,12 @@ as for reactions, and the window holds the opposite. Each node's charge balance
|
|
|
753
766
|
Gauss's law averaged across the section, $`-\partial_x(\varepsilon\,\partial_x\phi) = \rho + a\sigma`$,
|
|
754
767
|
and at $`\varepsilon = 0`$ neutrality $`\rho + a\sigma = 0`$, the mobile species rearranging to supply
|
|
755
768
|
it (counter-ions in, co-ions out). The port passes the charging current, $`d(\int a\sigma\,dV)/dt`$,
|
|
756
|
-
none in a steady state.
|
|
757
|
-
|
|
769
|
+
none in a steady state. `zeroCharge` is in the frame a contact's is (see the capacitive contact
|
|
770
|
+
above): $`V - \phi`$ at zero charge, with $`\phi`$ the device's own, which a bath or contact sets
|
|
771
|
+
(beside a bath referenced to Cl⁻, with the library's μ°, it sits near −1.4 V). Omitted, it's 0, so
|
|
772
|
+
a port held at V = 0 there starts charged by 1.4 V; to start it uncharged, give `zeroCharge` = V −
|
|
773
|
+
φ at its window (`sol.phi` after a solve). Capacitances on overlapping windows add (a channel's
|
|
774
|
+
top and bottom gates). A capacitance alone, driven by a current, only charges: it starts uncharged, `advance()`
|
|
758
775
|
follows it, and `solve()` refuses (there's no steady state, as for a gate contact driven by one).
|
|
759
776
|
|
|
760
777
|
```js nocheck
|
|
@@ -1004,14 +1021,19 @@ const now = dev.solution(); // snapshot of the current state
|
|
|
1004
1021
|
as a transient slows, so give `dtMax` to resolve a signal in time.
|
|
1005
1022
|
- `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
|
|
1006
1023
|
it: $`Z(f) = \delta V/\delta I`$ in Ω·m² at one terminal (`'right'` by default), with I into the
|
|
1007
|
-
device
|
|
1024
|
+
device, its input impedance whether it's held or driven by a current (for a driven one, about
|
|
1025
|
+
the state its drive holds: solve() it held, then set its I, solve(), then impedance()); the other terminals
|
|
1008
1026
|
keep their drives (held ones at AC ground, driven ones open), so at least one other must be held
|
|
1009
1027
|
for the current to return (with every other one driven, the impedance is infinite, and an error
|
|
1010
1028
|
says to measure at a driven one). A terminal behind a resistance can't be the one measured: the
|
|
1011
1029
|
resistance belongs to the external circuit. The grid must resolve how far the signal reaches,
|
|
1012
1030
|
and nothing warns of it: in a porous electrode, $`\sqrt{\kappa/(\omega C a)}`$ (cells of 2 µm
|
|
1013
1031
|
against a 3.4 µm depth put Z 7% off, falling as the cells do; at an electrode, the diffusion
|
|
1014
|
-
length $`\sqrt{2D/\omega}`$ at the highest frequency
|
|
1032
|
+
length $`\sqrt{2D/\omega}`$ at the highest frequency, and coarser than that, a port's window
|
|
1033
|
+
edges falling mid-cell moved a faradaic C 17%); and a double layer's capacitance wants cells
|
|
1034
|
+
well inside its Debye length (0.1 M NaCl: hmin λ_D/2.4 read C_dl 2.1% low, 0.1 nm 0.5%, 0.05 nm
|
|
1035
|
+
to 5e-4; the warning about unresolved double layers starts only at λ_D). `relError` is the
|
|
1036
|
+
round-off's, not the grid's. The result is `{ f, Z: { re, im },
|
|
1015
1037
|
relError, warnings }`: `relError[k]`, each Z's estimated error relative to $`|Z|`$ (from
|
|
1016
1038
|
round-off in the current and the solves' residuals, and cautious: it can exceed the true error
|
|
1017
1039
|
many times), and the warnings, the steady state's and one where a Z isn't resolved (an error
|
|
@@ -1022,7 +1044,10 @@ const now = dev.solution(); // snapshot of the current state
|
|
|
1022
1044
|
terminal is driven by a current.
|
|
1023
1045
|
- `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
|
|
1024
1046
|
or `I` drops the other (and given `I`, any series `R`): `{ contacts: { right: { I: 0 } } }`
|
|
1025
|
-
switches it to open circuit, whether it was held or behind a resistance.
|
|
1047
|
+
switches it to open circuit, whether it was held or behind a resistance. Given `V` alone, a terminal
|
|
1048
|
+
behind a resistance stays behind it: `{ V, R: undefined }` takes the resistance off (`R: 0` and
|
|
1049
|
+
`null` are refused, and `undefined` doesn't survive JSON, so a worker's patch says it this way in
|
|
1050
|
+
JavaScript). A patch that changes only the
|
|
1026
1051
|
terminals' drives (`V`, `I`, `R`) updates them in place, cheaply, keeping everything else.
|
|
1027
1052
|
Otherwise the device is rebuilt: the current state carries over while the species, regions
|
|
1028
1053
|
and terminals are unchanged (on a new grid, interpolated onto its nodes, so a floating gate
|
package/docs/kit.md
CHANGED
|
@@ -59,8 +59,9 @@ console.log(`I = ${sol.current.toFixed(1)} A/m²`);
|
|
|
59
59
|
infinite-recombination contact; `['e-']` alone is selective, and is the current collector on
|
|
60
60
|
a metal region). `bath(c, reference, drive, { offset, material })` is a bath whose terminal voltage is
|
|
61
61
|
the level of its `reference` species, as an electrode reversible to it would read (`offset`
|
|
62
|
-
places that species relative to the terminal voltage, e.g. to read a
|
|
63
|
-
against SHE).
|
|
62
|
+
places that species relative to the terminal voltage, its level at V + `offset`, e.g. to read a
|
|
63
|
+
solution's potentials against SHE). Every species a bath lists is held at its level there, so
|
|
64
|
+
one absent from the bulk (a product) is given as a trace, not 0. `bath(c, drive)`, without a reference, is a bath whose terminal voltage is its
|
|
64
65
|
φ: an ideal salt bridge, the convention of membrane potentials, so that between two such baths
|
|
65
66
|
V_right − V_left is the φ difference a voltage clamp sets. A drive is a voltage, a waveform, or
|
|
66
67
|
`{ V }`, `{ I }`, `{ V, R }`. A bath against a charged layer (an ion exchanger) gives its own
|
|
@@ -448,7 +449,10 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
|
|
|
448
449
|
term named by its equation; two with the same equation (radiative and SRH recombination, both
|
|
449
450
|
`e- + h+ = 0`) are told apart by their place, `e- + h+ = 0 (bulkReactions[1])`, and
|
|
450
451
|
`sol.bulkReactions[k]` has each one's rates. A species at rest
|
|
451
|
-
(in equilibrium, flows only of round-off against what it could carry) isn't listed.
|
|
452
|
+
(in equilibrium, flows only of round-off against what it could carry) isn't listed. Where none
|
|
453
|
+
is left, it says "equilibrium" only if no flow comes within ε of what it could carry; otherwise
|
|
454
|
+
it names the largest, which may be round-off or a real flow too small to check this way (a
|
|
455
|
+
reverse diode's 1 µA/m², 4e-15 of what its holes could carry).
|
|
452
456
|
- **conservation**, in a transient: every closed, unreacting stretch keeps what it held plus
|
|
453
457
|
what came in.
|
|
454
458
|
- **order**, in a steady state (unless `refine: false` or `order: false`): the device solved from
|
package/docs/numerics.md
CHANGED
|
@@ -661,10 +661,16 @@ About a steady state, a small sinusoidal excitation at one terminal gives, to fi
|
|
|
661
661
|
$`v \cdot K`$, displacement), and likewise $`C`$ and $`C'`$ for the terminal currents. The
|
|
662
662
|
time-derivative parts are read off two assemblies, $`J(dt) = J + M/dt`$, with a tiny $`dt`$ so that
|
|
663
663
|
the subtraction loses nothing.
|
|
664
|
-
- **At the measured terminal $`T`$,**
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
664
|
+
- **At the measured terminal $`T`$,** its voltage is perturbed ($`\delta V_T = 1`$) and
|
|
665
|
+
$`\delta I_T`$ read, held or driven by a current alike: $`Z = \delta V_T/\delta I_T`$ is its input
|
|
666
|
+
impedance either way, with $`I`$ into the device. (Solving a driven one's $`\delta V_T`$ for a unit
|
|
667
|
+
current instead read its current through its own circuit row, at the contact alone: a reverse
|
|
668
|
+
diode's 4.3% off $`dV/dI`$.) The other terminals keep their drives: held ones at AC ground,
|
|
669
|
+
driven ones open. A floating one's circuit row reads its current where that reading's error is
|
|
670
|
+
least: at its contact, or at any cut between it and a port's window, the same current (through a
|
|
671
|
+
metal it's the round-off of $`G\,\Delta\eta`$); and where every terminal but $`T`$ floats, the
|
|
672
|
+
response to $`T`$ with them held (nearly every level shifted alike, its current a difference of
|
|
673
|
+
large terms) is read through the identity that shifting every terminal alike passes nothing.
|
|
668
674
|
|
|
669
675
|
Each frequency costs one complex block-Thomas factorisation (rows scaled by their largest
|
|
670
676
|
entry), plus one back-substitution per floating terminal for the bordering. Conserved (blocked)
|
|
@@ -945,6 +951,17 @@ A solve that fails falls back stage by stage:
|
|
|
945
951
|
the floated solve fails, held solves find that voltage instead, by regula falsi within the
|
|
946
952
|
bracket, to 1e-10 of the current. (Not a terminal at no current that keeps an amount or a
|
|
947
953
|
charge: every voltage passes nothing there.)
|
|
954
|
+
- **A resistance.** A terminal behind a resistance R, its source at $`V_{\mathrm{src}}`$, is
|
|
955
|
+
found the same way, held at voltages marched from the held terminal's level until the
|
|
956
|
+
device's current crosses the load line's, $`(V_{\mathrm{src}} - V)/R`$, and floated from
|
|
957
|
+
beside it. Held at its source instead and the resistance put back, the state would sit
|
|
958
|
+
$`IR`$ from the answer, and the pseudo-transient relaxes that over $`RC`$ at the pace Newton
|
|
959
|
+
takes through the swing: a lit Schottky diode passing 0.9 A/m² took 0.05 s behind
|
|
960
|
+
1 Ω·m² and 105 s behind 10⁵ Ω·m² that way, and takes 33 ms behind any R by the load line
|
|
961
|
+
(80 fuzzed diodes from 0.01 to 10⁷ Ω·m²: 1835 s → 34 s). That route remains for terminals the load line doesn't
|
|
962
|
+
fit: several behind resistances, or one beside terminals all driven by currents, where
|
|
963
|
+
Kirchhoff gives its current and it's held at $`V_{\mathrm{src}} - IR`$ (from there, putting
|
|
964
|
+
$`R`$ back moves nothing).
|
|
948
965
|
- **The voltages.** The right terminal's voltage ramped to its target: from the contacts'
|
|
949
966
|
voltages at the last converged solve when the state is that solution (the left one first,
|
|
950
967
|
where it moved), and otherwise from level terminals, where a cold start is consistent. (A
|
package/docs/reliability.md
CHANGED
|
@@ -97,7 +97,7 @@ recipes.
|
|
|
97
97
|
|
|
98
98
|
The validation suite shows driftlet is right where it converges. Whether it converges, on
|
|
99
99
|
devices nobody tuned it for, is what `npm run stress` tests. It builds random but plausible
|
|
100
|
-
devices in
|
|
100
|
+
devices in six families:
|
|
101
101
|
|
|
102
102
|
| Family | What's drawn |
|
|
103
103
|
|---|---|
|
|
@@ -105,6 +105,7 @@ devices in five families:
|
|
|
105
105
|
| MOS capacitors | Si, Ge or GaAs, doped 1e15–1e18 cm⁻³; oxide 1–20 nm; Au, Al or Pt gate at any flat-band offset; with or without generation |
|
|
106
106
|
| Electrolyte cells | one to three salts from the data library's ions, 0.1 mM–1 M, 1 µm–1 mm; double layers resolved or strictly neutral; bath, reversible or blocking electrodes |
|
|
107
107
|
| Electrodes | Ag, Cu or Zn deposition, or Fe³⁺/Fe²⁺ on platinum, with Butler–Volmer kinetics over four decades of k₀; with or without a supporting salt; against a second electrode or a bath |
|
|
108
|
+
| Reacting solutions | complexations (Na⁺ + A⁻ = NaA, B + A⁻ = BA⁻, …) in 2–3 regions whose faces block some species, so conserved combinations overlap; against a bath, another bath, closed or a K⁺ electrode; sometimes an electrode at no current reducing a closed M⁺ |
|
|
108
109
|
| Liquid junctions | two mixed solutions, 1 µM–1 M, sharp or graded, at open circuit |
|
|
109
110
|
|
|
110
111
|
Each device goes through what a user would do with it: a cold solve at no bias, and one at a
|
|
@@ -114,26 +115,30 @@ after a voltage step; and the impedance about equilibrium, from far below its sl
|
|
|
114
115
|
to 1 GHz. Every result is judged by `check()`, and by what must hold whatever the device:
|
|
115
116
|
levels flat at equilibrium with no current, warm and cold solves agreeing, the current driven
|
|
116
117
|
being the one passed, the impedance passive (to 1e-3 of |Z|) and, at low frequency, equal to
|
|
117
|
-
the steady dI/dV, a MOS capacitance never above its oxide's, and its gate charge with the back
|
|
118
|
+
the steady dI/dV where it says it's resolved (`relError` under 1%), a MOS capacitance never above its oxide's, and its gate charge with the back
|
|
118
119
|
contact moved what it is with the gate moved the other way. Each case is seeded from its family and index, so
|
|
119
120
|
any failure reruns alone (`npm run stress -- semi 79`).
|
|
120
121
|
|
|
121
|
-
At 500 devices per family (about
|
|
122
|
+
At 500 devices per family (about five minutes on a desktop), 16,379 of 16,381 scenarios pass:
|
|
122
123
|
|
|
123
|
-
| Family | Cold at 0 V | Cold at bias | Warm sweep | Open circuit, current or back contact | Transient | Impedance |
|
|
124
|
-
|
|
125
|
-
| Semiconductor stacks | 500/500 | 500/500 | 500/500 | 105/105 | 500/500 | 395/395 |
|
|
126
|
-
| MOS capacitors | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
|
|
127
|
-
| Electrolyte cells | 500/500 | 500/500 | 500/500 | | 500/500 | 381/381 |
|
|
128
|
-
| Electrodes | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
|
|
129
|
-
|
|
|
124
|
+
| Family | Cold at 0 V | Cold at bias | Species reversed | Warm sweep | Open circuit, current or back contact | Transient | Impedance |
|
|
125
|
+
|---|---|---|---|---|---|---|---|
|
|
126
|
+
| Semiconductor stacks | 500/500 | 500/500 | 500/500 | 499/500 | 105/105 | 500/500 | 395/395 |
|
|
127
|
+
| MOS capacitors | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
|
|
128
|
+
| Electrolyte cells | 500/500 | 500/500 | 500/500 | 500/500 | | 500/500 | 381/381 |
|
|
129
|
+
| Electrodes | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 | 500/500 |
|
|
130
|
+
| Reacting solutions | 500/500 | 500/500 | 500/500 | 500/500 | | 500/500 | |
|
|
131
|
+
| Liquid junctions | | | | | 500/500 | 499/500 | |
|
|
130
132
|
|
|
131
133
|
(Impedance only where the device has an equilibrium to linearise about; open circuit only for
|
|
132
134
|
lit cells.) The MOS back contact came last: before steady solves held a group of reacting
|
|
133
135
|
carriers flat where it reaches one contact alone, 43 of the 500 failed it, 5 of them silently
|
|
134
|
-
(a gate charge up to 2× off).
|
|
135
|
-
coarse for its double layers
|
|
136
|
-
|
|
136
|
+
(a gate charge up to 2× off). Of the two that fail, one is among the known limits below, a junction on a grid far too
|
|
137
|
+
coarse for its double layers; the other is a diode whose current, a small difference of large fluxes, a warm sweep
|
|
138
|
+
reaches to 1.8e-6 of what a cold solve does, against the 1e-6 the check asks: Newton's tolerance, not the physics
|
|
139
|
+
(its own current varies 2.8e-6 along the device, its balances converged).
|
|
140
|
+
`bench/stress.json` keeps the summary, and CI runs `npm run stress -- --check` against it: any
|
|
141
|
+
scenario failing that passes there fails the build.
|
|
137
142
|
|
|
138
143
|
Most of what driftlet's numerics do differently began as a failure here: the impedance's
|
|
139
144
|
current read across the quietest cut of the device rather than at the contact, GMRES judged by
|
package/docs/statistics.md
CHANGED
|
@@ -73,6 +73,19 @@ of exponentially. With one species it's Langmuir filling of sites. The dilute re
|
|
|
73
73
|
$`c_{\mathrm{ref}}`$ is kept. To use the half-filling convention of site models
|
|
74
74
|
($`e^\zeta = x/(1-x)`$), set `cRef` equal to `cMax`.
|
|
75
75
|
|
|
76
|
+
`cMax` from an ion's size: a cube of side $`a`$ per site gives $`c_{\mathrm{max}} = 1/(N_A a^3)`$, so
|
|
77
|
+
$`a`$ = 0.66 nm (a hydrated ion, about) is 5.8 M, and 0.5 nm is 13 M. The model counts sites only:
|
|
78
|
+
D falling with concentration, solvent counterflow and ion pairing are left out. On a material from
|
|
79
|
+
`aqueous()`, add it to the material it returns:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { AVOGADRO } from 'driftlet';
|
|
83
|
+
import { aqueous } from 'driftlet/kit';
|
|
84
|
+
|
|
85
|
+
const aq = aqueous(['Cu2+', 'SO42-', 'H+']);
|
|
86
|
+
aq.materials.water.statistics = [{ type: 'lattice', species: ['Cu2+', 'SO42-', 'H+'], cMax: 1 / (AVOGADRO * 0.66e-9 ** 3) }];
|
|
87
|
+
```
|
|
88
|
+
|
|
76
89
|
### Redlich–Kister
|
|
77
90
|
|
|
78
91
|
One species on its own $`c_{\mathrm{max}}`$ sites, with the excess Gibbs energy of mixing per site
|
|
@@ -92,7 +105,11 @@ $`\ln\gamma_i = -z_i^2 \ell_B \kappa / (2(1 + \kappa a))`$, with the Bjerrum len
|
|
|
92
105
|
$`\ell_B = e^2/(4\pi\varepsilon kT)`$ and $`\kappa^2 = F^2 \sum z^2 c / (\varepsilon RT)`$ from the
|
|
93
106
|
solvent permittivity `epsr` (which defaults to the material's, if that's non-zero) and a
|
|
94
107
|
distance of closest approach `a` (default 0, the limiting law). It stops being convex at high
|
|
95
|
-
ionic strength, and the solver then reports a failure.
|
|
108
|
+
ionic strength, and the solver then reports a failure. It goes on a material's `statistics`, as
|
|
109
|
+
any model does; with `aqueous()`, on the piece it returns: `lib.materials.water.statistics =
|
|
110
|
+
[{ type: 'debye-huckel', species: ['H+', 'Cl-'], a: 4.5e-10 }]`. `a` is an ion size (a few
|
|
111
|
+
ångström: tabulated per ion, as Kielland's sizes, or fitted to measured γ±), and it matters:
|
|
112
|
+
in 10 mM HCl, 4.5 Å against the limiting law moves a concentration cell's voltage by 0.6 mV.
|
|
96
113
|
|
|
97
114
|
Single-ion activity coefficients are a convention: only neutral combinations (the mean
|
|
98
115
|
activity $`\gamma_\pm`$) are measurable. In a strictly neutral ($`\varepsilon = 0`$) material only
|
package/llms.txt
CHANGED
|
@@ -30,9 +30,9 @@ materials, insertion hosts), data.md and reliability.md (what's validated, and t
|
|
|
30
30
|
From a CDN, pin the version you tested against:
|
|
31
31
|
|
|
32
32
|
```js nocheck
|
|
33
|
-
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
34
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
35
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
33
|
+
import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js';
|
|
34
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/kit.js';
|
|
35
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/plot.js';
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`. Every export is listed at the end.
|
|
@@ -219,9 +219,9 @@ a CDN needs a long virtual-time budget, 30 s.)
|
|
|
219
219
|
<span id="readout"></span>
|
|
220
220
|
<div id="figure"></div>
|
|
221
221
|
<script type="module">
|
|
222
|
-
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
223
|
-
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
224
|
-
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.
|
|
222
|
+
import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/index.js';
|
|
223
|
+
import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/kit.js';
|
|
224
|
+
import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@1.3.3/src/plot.js';
|
|
225
225
|
|
|
226
226
|
const def = build({
|
|
227
227
|
T: 300,
|
|
@@ -317,6 +317,8 @@ const mos = (Vg, zeroCharge) =>
|
|
|
317
317
|
layer('Si', units.um(1), { name: 'Si', acceptors: NA }),
|
|
318
318
|
ohmic(0),
|
|
319
319
|
],
|
|
320
|
+
// The inversion layer's supply: with it, the low-frequency C–V at every frequency, even
|
|
321
|
+
// impedance at 1 MHz. Leave it out for the high-frequency curve (see below the code).
|
|
320
322
|
ports: [{ name: 'channel', region: 'Si', from: 0, to: units.nm(1), V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' } }],
|
|
321
323
|
grid: { hmin: units.nm(0.25), hmax: units.nm(50), ratio: 1.15 },
|
|
322
324
|
});
|
|
@@ -383,8 +385,9 @@ electrons' V minus φ in the bulk (with the library's μ°), and a Cl⁻ termina
|
|
|
383
385
|
that, so an electrode held at 0 V beside it is strongly anodic: `describe(def)` says where SHE
|
|
384
386
|
sits, and an offset of that much (`bath(c, 'Cl-', 0, { offset })`) makes the terminal read SHE.
|
|
385
387
|
The working electrode's current is positive when oxidising. The layer's resistance is in series
|
|
386
|
-
(uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of salt
|
|
387
|
-
leaves the limiting current about 1% off pure diffusion
|
|
388
|
+
(uncompensated IR: keep it thin, or the supporting salt strong); a 100:1 excess of a 1:1 salt
|
|
389
|
+
still leaves the limiting current about 1% off pure diffusion, and migration counts for more with
|
|
390
|
+
more charge: Cu²⁺ in 1 M H₂SO₄ (20:1) reads 3% above it, with no acid 2×.
|
|
388
391
|
|
|
389
392
|
```js
|
|
390
393
|
import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
|
|
@@ -555,7 +558,8 @@ species }` (its flux) or `{ interface: f, gate }`.
|
|
|
555
558
|
(docs/statistics.md). At no current (`I: 0`), `solve()` keeps the charge it holds: a closed
|
|
556
559
|
battery sits at the OCV of its state of charge, a floating gate keeps its charge.
|
|
557
560
|
- **Other steps.** `dev.step(dt)` is one backward-Euler step, and `dev.impedance(frequencies,
|
|
558
|
-
{ terminal })` the small-signal Z(f) about the steady state, in Ω·m
|
|
561
|
+
{ terminal })` the small-signal Z(f) about the steady state, in Ω·m²: `{ f, Z: { re, im } }`,
|
|
562
|
+
each an array over the frequencies (`Z.re[k]`, not `Z[k].re`).
|
|
559
563
|
|
|
560
564
|
```js
|
|
561
565
|
import { Device, units, FARADAY } from 'driftlet';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "driftlet",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"description": "Dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for semiconductors, electrochemistry and nerves, fast enough for live web demos",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/src/checks.js
CHANGED
|
@@ -181,6 +181,12 @@ function balance(device, sol) {
|
|
|
181
181
|
// 1e-19 of its scale, to 1e-15), and round-off doesn't. (Above 1e-8 of the scale, any.)
|
|
182
182
|
const balanced = (l) => Math.abs(l.terms.reduce((a, t) => a + t.rate, 0)) <= 1e-9 * size(l);
|
|
183
183
|
const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l) || (size(l) > 1e-20 * scale(l) && balanced(l)));
|
|
184
|
+
// What's left unbalanced can be round-off or a small real flow the solve didn't settle (a
|
|
185
|
+
// reverse diode behind a resistance, 1.3 µA/m² at 4e-15 of its scale, balanced to 8e-5 by a
|
|
186
|
+
// solve that stalled), and nothing here tells them apart: round-off reaches 8e-12 of the scale
|
|
187
|
+
// in the stress devices, and one in a thousand of those flows balances to 1%. So above ε of the
|
|
188
|
+
// scale it isn't called equilibrium: the largest is named, as what balance can't check.
|
|
189
|
+
const unsure = ledgers.filter((l) => !moving.includes(l) && size(l) > 1e-16 * scale(l)).sort((a, b) => size(b) / scale(b) - size(a) / scale(a))[0];
|
|
184
190
|
let worst = 0;
|
|
185
191
|
const lines = [];
|
|
186
192
|
for (const l of moving) {
|
|
@@ -195,7 +201,9 @@ function balance(device, sol) {
|
|
|
195
201
|
const ok = worst < 1e-6;
|
|
196
202
|
const summary =
|
|
197
203
|
moving.length === 0
|
|
198
|
-
?
|
|
204
|
+
? unsure
|
|
205
|
+
? `nothing flows that it can tell from round-off: the largest, ${unsure.title} at ${sig(size(unsure))} ${units.rate}${unsure.z !== 0 ? ` (${sig(FARADAY * Math.abs(unsure.z) * size(unsure))} ${units.current})` : ''}, is ${sig(size(unsure) / scale(unsure), 1)} of what it could carry and doesn't balance (to ${sig(Math.abs(unsure.terms.reduce((a, t) => a + t.rate, 0)) / size(unsure), 1)}), so it's round-off, or a flow too small for this check`
|
|
206
|
+
: 'nothing flows or reacts (equilibrium)'
|
|
199
207
|
: `${moving.length === 1 ? 'one ledger sums' : `${moving.length} ledgers sum`} to zero ${ok ? `(to ${sig(worst, 1)})` : `only to ${sig(worst, 2)}: not a steady state`}: each species' sources and sinks, ${units.rate}, + in or made`;
|
|
200
208
|
// The largest current any charged species carries anywhere: the scale a net current is read against.
|
|
201
209
|
const gross = Math.max(0, ...moving.filter((l) => l.z !== 0).map((l) => FARADAY * Math.abs(l.z) * size(l)));
|
package/src/describe.js
CHANGED
|
@@ -114,6 +114,8 @@ export function unitWarnings(def) {
|
|
|
114
114
|
if (!isObject(p)) return;
|
|
115
115
|
capacitance(`ports[${k}].capacitance.C`, p.capacitance?.C);
|
|
116
116
|
kinetics(`ports[${k}]`, p.reactions);
|
|
117
|
+
// (an area per volume typed in cm²/cm³ is 100 times too small: a mesh at 10 cm²/cm³ went by)
|
|
118
|
+
if (typeof p.area === 'number' && p.area > 0 && p.area < 100) warn(`ports[${k}].area`, `${num(p.area)} m²/m³ is under 1 cm² of electrode per cm³; area is m²/m³ (1 cm²/cm³ = 100 m²/m³)`);
|
|
117
119
|
});
|
|
118
120
|
for (const side of ['left', 'right']) {
|
|
119
121
|
const c = def.contacts?.[side];
|
package/src/device.js
CHANGED
|
@@ -811,6 +811,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
|
|
|
811
811
|
need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
812
812
|
const i = speciesIndex.get(sname);
|
|
813
813
|
need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${reg.name} (material '${mat.name}'), so its level outside means nothing here`);
|
|
814
|
+
need(v !== 0, `${path}.bath.c.${sname}: a bath holds each species it lists at its level, and at 0 there is none (ln 0); a species absent from the bulk (a product, say) is a trace there, 1e-6 of the rest or so, so that it can leave`);
|
|
814
815
|
cb[i] = positive(v, `${path}.bath.c.${sname}`);
|
|
815
816
|
}
|
|
816
817
|
const zb = bathZeta(mat, cb, reg.background);
|
|
@@ -1359,6 +1360,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1359
1360
|
need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
1360
1361
|
const i = speciesIndex.get(sname);
|
|
1361
1362
|
need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
|
|
1363
|
+
need(v !== 0, `${path}.bath.c.${sname}: a bath holds each species it lists at its level, and at 0 there is none (ln 0); a species absent from the bulk (a product, say) is a trace there, 1e-6 of the rest or so, so that it can leave`);
|
|
1362
1364
|
cb[i] = positive(v, `${path}.bath.c.${sname}`);
|
|
1363
1365
|
charge += species[i].z * cb[i];
|
|
1364
1366
|
scale += Math.abs(species[i].z) * cb[i];
|
|
@@ -1413,6 +1415,20 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1413
1415
|
links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0),
|
|
1414
1416
|
`${path}.phi: 'bulk' needs at least one connected charged species at this contact`,
|
|
1415
1417
|
);
|
|
1418
|
+
// Neutral at the contact must be possible: an undoped layer of holes alone never is, and
|
|
1419
|
+
// ohmic(V, ['h+']) on one failed with "nothing specific showed why" (round 16's cold agent).
|
|
1420
|
+
if (!mat.conductor && !mat.phiFree && !mat.models.some((md) => md.type === 'insertion')) {
|
|
1421
|
+
const signs = new Set(species.flatMap((sp, i) => (mat.present[i] && sp.z !== 0 ? [Math.sign(sp.z)] : [])));
|
|
1422
|
+
const fixed = Math.sign(region.fixedCharge ?? 0);
|
|
1423
|
+
if (signs.size === 1 && !(fixed !== 0 && !signs.has(fixed))) {
|
|
1424
|
+
const [only] = signs;
|
|
1425
|
+
throw new DeviceError(
|
|
1426
|
+
`${path}.phi: 'bulk' (what ohmic() and a bath use) holds the contact neutral, but '${region.name ?? region.material}' can't be: ` +
|
|
1427
|
+
`its only charged species are ${only > 0 ? 'positive' : 'negative'} and no fixed charge balances them (an undoped layer of one carrier). ` +
|
|
1428
|
+
"For a contact that sets the carrier's density, pin φ instead: phi: 'pinned' with zeroCharge, the contact's level against the carrier's (see docs/device.md, contacts).",
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1416
1432
|
}
|
|
1417
1433
|
|
|
1418
1434
|
if (terminal !== null) {
|