driftlet 0.16.4 → 1.0.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 +3 -3
- package/docs/device.md +13 -5
- package/docs/kit.md +3 -1
- package/docs/numerics.md +12 -0
- package/docs/statistics.md +2 -1
- package/docs/templates.md +260 -0
- package/llms.txt +106 -300
- package/package.json +15 -3
- package/src/channels.js +6 -4
- package/src/checks.js +7 -2
- package/src/data.js +5 -1
- package/src/device.js +1 -1
- package/src/devices.js +2 -0
- package/src/equation.js +2 -1
- package/src/errors.js +33 -0
- package/src/grid.js +12 -0
- package/src/index.js +17 -7
- package/src/junction.js +3 -1
- package/src/kit.js +1 -1
- package/src/live.js +2 -0
- package/src/particles.js +2 -0
- package/src/plot.js +4 -0
- package/src/polarization.js +2 -1
- package/src/recorder.js +2 -1
- package/src/solution.js +20 -5
- package/src/solver.js +22 -2
- package/src/sources.js +8 -1
- package/src/stack.js +14 -1
- package/src/statistics.js +1 -1
- package/src/traces.js +3 -0
- package/src/types.js +3 -1
- package/types/channels.d.ts +3 -3
- package/types/checks.d.ts +1 -1
- package/types/data.d.ts +3 -3
- package/types/devices.d.ts +1 -1
- package/types/equation.d.ts +1 -1
- package/types/errors.d.ts +8 -0
- package/types/index.d.ts +2 -5
- package/types/junction.d.ts +2 -2
- package/types/kit.d.ts +1 -1
- package/types/live.d.ts +1 -1
- package/types/particles.d.ts +1 -1
- package/types/plot.d.ts +3 -3
- package/types/polarization.d.ts +1 -1
- package/types/recorder.d.ts +1 -1
- package/types/sources.d.ts +7 -7
- package/types/traces.d.ts +1 -1
- package/types/types.d.ts +9 -1
package/README.md
CHANGED
|
@@ -40,8 +40,8 @@ cells, electrodes) put through cold solves, sweeps, transients and impedance, ev
|
|
|
40
40
|
checked. A solve that fails reports it, with what went wrong where it can tell.
|
|
41
41
|
[How far to trust it](docs/reliability.md) has the details, the numbers and the known limits.
|
|
42
42
|
|
|
43
|
-
**Status: 0
|
|
44
|
-
|
|
43
|
+
**Status: 1.0.** The API is stable: a change that breaks working code comes only with a major
|
|
44
|
+
version.
|
|
45
45
|
|
|
46
46
|
## Install
|
|
47
47
|
|
|
@@ -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@0.
|
|
59
|
+
`https://cdn.jsdelivr.net/npm/driftlet@1.0.0/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
|
@@ -173,7 +173,12 @@ defaults to no dipole.
|
|
|
173
173
|
in F/m². Beside a strictly neutral ($`\varepsilon = 0`$) material, the face's charge is held in
|
|
174
174
|
the cell next to it, which acts as a diffuse layer half that cell wide, $`F^2 \sum_i z_i^2 c_i \, h/2RT`$
|
|
175
175
|
in series with C. So keep that cell coarse rather than fine (a warning says when it costs C more
|
|
176
|
-
than 2%, and how coarse is enough).
|
|
176
|
+
than 2%, and how coarse is enough). A face reaction there sees that cell's composition, which
|
|
177
|
+
holding the charge shifts by about $`z\sigma F/(C_{\mathrm{cell}} RT)`$ for an ion of charge $`z`$: a
|
|
178
|
+
Frumkin effect set by the mesh, growing without limit as the cell shrinks (an electrode's DC
|
|
179
|
+
resistance in ferri/ferrocyanide fell from 56.6 to 47.5 Ω·cm² between 300 nm and 1 nm). A
|
|
180
|
+
warning says past 1%; a `zeroCharge` near the face's rest potential, so it holds little charge,
|
|
181
|
+
avoids it on any grid. A sub-grid Gouy–Chapman law is on the [roadmap](../ROADMAP.md).
|
|
177
182
|
|
|
178
183
|
**Species laws** `species` (default: local equilibrium, $`\bar\mu`$ continuous, where the species is
|
|
179
184
|
present on both sides and can move on one at least; blocked otherwise, so trap states with
|
|
@@ -969,9 +974,12 @@ const now = dev.solution(); // snapshot of the current state
|
|
|
969
974
|
says to measure at a driven one). A terminal behind a resistance can't be the one measured: the
|
|
970
975
|
resistance belongs to the external circuit. The grid must resolve how far the signal reaches,
|
|
971
976
|
and nothing warns of it: in a porous electrode, $`\sqrt{\kappa/(\omega C a)}`$ (cells of 2 µm
|
|
972
|
-
against a 3.4 µm depth put Z 7% off, falling as the cells do
|
|
973
|
-
|
|
974
|
-
|
|
977
|
+
against a 3.4 µm depth put Z 7% off, falling as the cells do; at an electrode, the diffusion
|
|
978
|
+
length $`\sqrt{2D/\omega}`$ at the highest frequency). The result is `{ f, Z: { re, im },
|
|
979
|
+
warnings }`, the warnings being the steady state's. With `profiles: true`, `profiles[k]` holds
|
|
980
|
+
frequency k's complex profiles, `{ phi, mu: {name}, c: {name} }`, each `{ re, im }` at the
|
|
981
|
+
nodes (`NaN` where a quantity doesn't exist), per unit excitation: a volt, or an A/m² where the
|
|
982
|
+
terminal is driven by a current.
|
|
975
983
|
- `set(patch)` merges plain objects deeply (arrays are replaced), except that a contact given `V`
|
|
976
984
|
or `I` drops the other (and given `I`, any series `R`): `{ contacts: { right: { I: 0 } } }`
|
|
977
985
|
switches it to open circuit, whether it was held or behind a resistance. A patch that changes only the
|
|
@@ -1027,7 +1035,7 @@ console.log(`${run.steps} steps; I(0.01 s) ≈ ${run.trace.current[run.trace.t.f
|
|
|
1027
1035
|
| `current`, `terminalVoltage` | current toward +x through the device (A/m², or A through a [cross-section](#geometry)) and $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ |
|
|
1028
1036
|
| `terminals[name]` | `{ V, current }` for each terminal (contacts and ports), current into the device |
|
|
1029
1037
|
| `contacts.left/right` | `{ V, flux: {name}, D, current, links: {name} }` at each contact, flux and current toward +x (out of the device at the right contact; `terminals` give it into the device); `links` names how each species that crosses it does (`'equilibrium'`, `'velocity'`, …) |
|
|
1030
|
-
| `
|
|
1038
|
+
| `plates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
|
|
1031
1039
|
| `ports[k]` | `{ name, V, flux: {name}, current }`: what each internal port brings into the device (an electrode's `flux` is of what its reactions make and use in the solution, its carrier's being outside); an electrode's rates, coverages and charge [as well](#what-a-solution-reports) |
|
|
1032
1040
|
| `interfaces[f]` | `{ left, right, dipole, sheetCharge, D, N: {name}, rates, links: {name} }`: the names of the regions the face joins, what crosses it by its links, each face reaction's rate (mol/(m²·s)), and how each species that crosses it does; for those crossing by permeability, `oneWay: { name: [toward +x, toward −x] }`, the one-way fluxes whose difference is `N` (Ussing's unidirectional fluxes) |
|
|
1033
1041
|
| `bulkReactions[k]` | `{ rate, forward, nu, regions, total }`: each bulk reaction's net forward rate at every node (mol/(m³·s), `NaN` where it doesn't run), its one-way forward rate (the backward is `forward − rate`), what one forward reaction makes of each species (`nu`, negative for what it consumes), and integrated over each region and the device (mol/(m²·s)), as the balances count it: a charge-balance check is J = F(generation − recombination), which the kit's `check()` does for every species |
|
package/docs/kit.md
CHANGED
|
@@ -438,7 +438,9 @@ page can show that its numbers hold up. Pass the solution `solve()` or `advance(
|
|
|
438
438
|
- **order**, in a steady state (unless `refine: false` or `order: false`): the device solved from
|
|
439
439
|
its definition twice, its species listed as given and in reverse. The same physics must give
|
|
440
440
|
the same steady state, each terminal's voltage and current and each species' amount; where it
|
|
441
|
-
doesn't, one of the two solves converged somewhere that isn't steady, without noticing
|
|
441
|
+
doesn't, one of the two solves converged somewhere that isn't steady, without noticing, or the
|
|
442
|
+
device has more than one steady state, which Newton picks by its path (an unstable one too:
|
|
443
|
+
see [numerics](numerics.md#steady-state) for kicking a state to test it).
|
|
442
444
|
That's rare (round 11's fuzzer found it in ~1% of random reacting devices, by this same test),
|
|
443
445
|
but it's the cheapest check there is on a state the other items can't see past. Both solves
|
|
444
446
|
start from the definition, not this state, so it costs two solves.
|
package/docs/numerics.md
CHANGED
|
@@ -681,6 +681,18 @@ the clock. Nor does it ask whether the steady state is stable: an axon held at a
|
|
|
681
681
|
makes it fire over and over has a resting point all the same, which `solve()` returns, and only
|
|
682
682
|
a transient leaves it.
|
|
683
683
|
|
|
684
|
+
Reactions far from equilibrium can have more than one steady state, and Newton finds whichever
|
|
685
|
+
its path leads to, unstable ones included. Two couples on electrode ports (round 11's fuzzer)
|
|
686
|
+
had a state near the fast couple's equilibrium and one with its reactant at 1e-18 mol/m³; the
|
|
687
|
+
second passed every check, and two listings of three solved to it, yet a 10 mV kick took it to
|
|
688
|
+
the first within 2e5 s (where a long `advance()` from the start goes too). A transient from the
|
|
689
|
+
unstable state doesn't show this either: its steps soon outgrow the growth time (~3000 s here),
|
|
690
|
+
and an implicit step that long damps a growing mode as it damps a decaying one, so it sits there
|
|
691
|
+
to 1e12 s. To test a steady state, kick it (a drive moved for a moment and put back) and
|
|
692
|
+
advance with `dtMax` held below the times you care about, a tenth of each interval as it
|
|
693
|
+
grows: a stable state comes back. (At equilibrium, nothing flowing, a state minimises a convex
|
|
694
|
+
free energy and is stable.)
|
|
695
|
+
|
|
684
696
|
### The steady system
|
|
685
697
|
|
|
686
698
|
Every row of a steady solve that differs from a time step's is a sum of balance rows, or (a
|
package/docs/statistics.md
CHANGED
|
@@ -140,7 +140,8 @@ therefore reproduced exactly, and $`x`$ stays within $`(0, 1)`$ at any potential
|
|
|
140
140
|
curve is checked for monotonicity at construction. A curve far flatter than the ideal term near
|
|
141
141
|
an end (E falling by millivolts over the last few percent of x) can fail that check however
|
|
142
142
|
many points it has, since r then changes as steeply as the logarithm: give the ends some of the
|
|
143
|
-
ideal slope
|
|
143
|
+
ideal slope: adding −a (RT/F) ln(x/(1−x)) to E, with a = 0.1 at points 0.01 apart (less with points
|
|
144
|
+
closer together near the ends), passes.
|
|
144
145
|
|
|
145
146
|
With `A` instead, the curve is the isotherm's, against a reference where the combination's
|
|
146
147
|
chemical potential is 0, with $`g`$ the excess chemical potential ($`\Omega(1-2x)`$ for `A: [Ω]`):
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# More templates
|
|
2
|
+
|
|
3
|
+
Templates for devices outside [llms.txt](../llms.txt)'s core set, written the same way: each
|
|
4
|
+
block runs in the test suite and prints its closed-form check. Read llms.txt first; these build
|
|
5
|
+
on it.
|
|
6
|
+
|
|
7
|
+
- semiconductors: [a Schottky diode](#a-schottky-diode), [a thin-film transistor](#a-thin-film-transistor-a-capacitance-along-a-channel);
|
|
8
|
+
- electrochemistry: [corrosion under a drop](#corrosion-under-a-drop-an-electrode-spread-through-a-film)
|
|
9
|
+
(an electrode spread through a film, and a cross-section that varies);
|
|
10
|
+
- physiology: [a voltage clamp](#a-voltage-clamp), [an action potential](#an-action-potential),
|
|
11
|
+
[one travelling along an axon](#an-action-potential-travelling-along-an-axon).
|
|
12
|
+
|
|
13
|
+
## a Schottky diode
|
|
14
|
+
|
|
15
|
+
A barrier pinned at the metal (`phi: 'pinned'`, `zeroCharge` the barrier height for Si, whose
|
|
16
|
+
$`\mu^\circ_{\mathrm{e}^-}`$ is 0), crossed at the emission velocity: a `velocity` link, $`N_{\mathrm{in}} = v\,(c_{\mathrm{eq}} - c)`$. With
|
|
17
|
+
`'equilibrium'` links (infinite velocity) it's diffusion theory instead, 20 times the current here.
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { Device, units, FARADAY, GAS_CONSTANT } from 'driftlet';
|
|
21
|
+
import { build, layer, ohmic, semiconductor } from 'driftlet/kit';
|
|
22
|
+
|
|
23
|
+
const T = 300, VT = (GAS_CONSTANT * T) / FARADAY, Si = semiconductor('Si');
|
|
24
|
+
const phiB = 0.85, A = 1.12e6; // the barrier (V); Richardson's constant for n-Si, A/(m²K²)
|
|
25
|
+
const v = (A * T * T) / (FARADAY * Si.materials.Si.species['e-'].cRef); // Bethe's emission velocity, 2.2e4 m/s (cRef is N_c)
|
|
26
|
+
const schottky = (V) => ({
|
|
27
|
+
V,
|
|
28
|
+
terminal: 'e-',
|
|
29
|
+
species: { 'e-': { type: 'velocity', v }, 'h+': { type: 'velocity', v, offset: 0 } },
|
|
30
|
+
phi: 'pinned',
|
|
31
|
+
zeroCharge: phiB, // E_c − E_F at the metal
|
|
32
|
+
});
|
|
33
|
+
const dev = new Device(build({ T, library: [Si], stack: [schottky(0), layer('Si', units.um(2), { donors: units.perCm3(1e16) }), ohmic(0)], grid: { hmin: units.nm(1), hmax: units.nm(20) } }));
|
|
34
|
+
for (const V of [0.1, 0.2, 0.3]) {
|
|
35
|
+
dev.set({ contacts: { left: { V } } }); // the metal positive: forward
|
|
36
|
+
const J = dev.solve().current, te = A * T * T * Math.exp(-phiB / VT) * Math.expm1(V / VT);
|
|
37
|
+
console.log(`${V} V: J ${J.toExponential(3)} A/m², thermionic emission ${te.toExponential(3)}`); // about 4% below: the depletion region in series
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The same `velocity` link at an ohmic contact is its surface recombination velocity.
|
|
42
|
+
|
|
43
|
+
## a thin-film transistor (a capacitance along a channel)
|
|
44
|
+
|
|
45
|
+
A **port with a `capacitance`** holds charge $`\sigma = C\,(V - \mathtt{zeroCharge} - \phi)`$ per area of electrode
|
|
46
|
+
along its window, balanced by the region's: a gate along a channel, a porous electrode's double
|
|
47
|
+
layer. Here x runs from source to drain with the gate spread along it: the gradual-channel
|
|
48
|
+
approximation, exact for a thin film until it pinches off.
|
|
49
|
+
|
|
50
|
+
```js
|
|
51
|
+
import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
|
|
52
|
+
|
|
53
|
+
const T = 300, VT = (GAS_CONSTANT * T) / FARADAY;
|
|
54
|
+
const t = 20e-9, L = 10e-6, W = 100e-6, C = 1e-3, mu = 1e-3; // film, length, width (m); F/m² of gate; m²/(V·s)
|
|
55
|
+
const tft = (Vg, Vd) =>
|
|
56
|
+
new Device({
|
|
57
|
+
T,
|
|
58
|
+
species: [{ name: 'e-', z: -1, cRef: 46 }], // an undoped film's electrons (N_c 2.8e19 cm⁻³)
|
|
59
|
+
materials: { film: { epsr: 0, species: { 'e-': { D: mu * VT, mu0: 0 } } } }, // ε = 0: F·n·t balances σ
|
|
60
|
+
regions: [{ name: 'channel', material: 'film', length: L }],
|
|
61
|
+
contacts: {
|
|
62
|
+
left: { V: 0, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // source
|
|
63
|
+
right: { V: Vd, terminal: 'e-', species: { 'e-': 'equilibrium' }, phi: 'neutral' }, // drain
|
|
64
|
+
},
|
|
65
|
+
ports: [{ name: 'gate', region: 'channel', V: Vg, area: 1 / t, capacitance: { C, zeroCharge: 0.3 } }],
|
|
66
|
+
grid: { hmin: L / 2000, hmax: L / 100, ratio: 1.05 }, // fine at the drain, where it pinches off
|
|
67
|
+
});
|
|
68
|
+
// The output characteristic at V_G = 2 V: linear, then saturating past V_D ≈ V_G − 0.3 V.
|
|
69
|
+
for (const Vd of [0.1, 0.5, 1, 1.5, 2, 3]) {
|
|
70
|
+
const sol = tft(2, Vd).solve();
|
|
71
|
+
const I = -sol.current * t * W; // sol.current is per m² of the film's section
|
|
72
|
+
console.log(`V_D ${Vd.toFixed(1)} V: I_D ${(I * 1e6).toFixed(2)} µA, σ at the drain ${(sol.ports[0].sigma.at(-1) * 1e3).toFixed(3)} mC/m²`);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
To sweep the gate on one device instead, patch the port: `dev.set({ ports: { gate: { V } } })`.
|
|
77
|
+
An organic electrochemical transistor is the same with a volumetric capacitance C*:
|
|
78
|
+
`area: 1 / t, capacitance: { C: Cstar * t }`.
|
|
79
|
+
|
|
80
|
+
## corrosion under a drop (an electrode spread through a film)
|
|
81
|
+
|
|
82
|
+
A **port with reactions** is an electrode spread through a strictly neutral region: a metal under
|
|
83
|
+
a film of water, a crevice's walls, a porous electrode. Its `terminal` is the metal's carrier,
|
|
84
|
+
`area` its area per volume (1/h under a film h thick), each reaction Butler–Volmer per area, and
|
|
85
|
+
a `capacitance` the double layer. A **`geometry`** gives the device a cross-section A(x): here x
|
|
86
|
+
is the radius of a drop, $`A = 2\pi r\,h(r)`$, and currents and amounts become totals (A, mol).
|
|
87
|
+
|
|
88
|
+
```js
|
|
89
|
+
import { Device, GAS_CONSTANT, FARADAY } from 'driftlet';
|
|
90
|
+
import { aqueous, polarization, H2O } from 'driftlet/kit';
|
|
91
|
+
|
|
92
|
+
// Salt water on iron: a drop 3 mm across. The iron dissolves and reduces O₂, which comes in from
|
|
93
|
+
// the air through the drop's top, fastest at the thin rim: the centre corrodes, the rim doesn't.
|
|
94
|
+
const T = 298.15, RT = GAS_CONSTANT * T, R = 3e-3, h0 = 1e-3, cO2 = 0.26, D = 2e-9;
|
|
95
|
+
const lib = aqueous(['Na+', 'Cl-', 'Fe2+', 'OH-', 'H+'], { epsr: 0 }); // strictly neutral water
|
|
96
|
+
lib.species.push({ name: 'O2', z: 0, cRef: 1000 }, { name: 'e-', z: -1 }); // e⁻: the iron's, in no material
|
|
97
|
+
lib.materials.water.species.O2 = { D, mu0: 16.4e3 };
|
|
98
|
+
const r = Array.from({ length: 61 }, (_, j) => (j / 60) * R);
|
|
99
|
+
const h = r.map((x) => Math.max(h0 * (1 - (x / R) * (x / R)), 0.02 * h0)); // the drop's height
|
|
100
|
+
const dev = new Device({
|
|
101
|
+
T,
|
|
102
|
+
species: lib.species,
|
|
103
|
+
materials: lib.materials,
|
|
104
|
+
geometry: { area: { x: r, values: r.map((x, j) => 2 * Math.PI * x * h[j]) } }, // ions squeeze through the thin rim
|
|
105
|
+
regions: [{ name: 'drop', material: 'water', length: R, c0: { 'Na+': 500, 'Cl-': 500 + 2e-6, 'Fe2+': 1e-6, 'OH-': 1e-4, 'H+': 1e-4, O2: cO2 } }],
|
|
106
|
+
contacts: { left: { phi: 'neutral' }, right: { phi: 'neutral' } }, // closed: the centre, and the rim
|
|
107
|
+
bulkReactions: [
|
|
108
|
+
{ equation: 'H+ + OH- = H2O', fixed: { H2O }, kf: { water: 1.4e8 } }, // H2O: water's μ, −237.129 kJ/mol
|
|
109
|
+
// O₂ from the air through the drop: a rate k_f (1 − c/c_sat), k_f = D c_sat/h² (not a port's
|
|
110
|
+
// exchange link, whose rate is linear in μ rather than c)
|
|
111
|
+
{ equation: 'Air = O2', fixed: { Air: 16.4e3 + RT * Math.log(cO2 / 1000) }, kf: { water: { x: r, values: h.map((y) => (D * cO2) / (y * y)) } } },
|
|
112
|
+
],
|
|
113
|
+
ports: [{
|
|
114
|
+
name: 'iron', region: 'drop', terminal: 'e-',
|
|
115
|
+
V: 0, // the only metal and the only path for current, so it passes none: held, it fixes the level φ is read against
|
|
116
|
+
area: { x: r, values: h.map((y) => 1 / y) }, // m² of iron per m³ of water
|
|
117
|
+
capacitance: { C: 0.2, zeroCharge: -0.4 }, // the double layer: the potential moves continuously
|
|
118
|
+
reactions: [
|
|
119
|
+
{ equation: 'Fe2+ + 2 e- = Fe(s)', fixed: { 'Fe(s)': 0 }, k0: 5e-6, alpha: 0.5 },
|
|
120
|
+
{ equation: 'O2 + 2 H2O + 4 e- = 4 OH-', fixed: { H2O }, k0: 3e-9, alpha: 0.125 },
|
|
121
|
+
],
|
|
122
|
+
}],
|
|
123
|
+
grid: { hmin: 5e-6, hmax: 60e-6, ratio: 1.2 },
|
|
124
|
+
});
|
|
125
|
+
const sol = dev.advance(600); // ten minutes
|
|
126
|
+
const iron = sol.ports[0], last = iron.x.length - 1;
|
|
127
|
+
// rates[q][j]: reaction q at node j of the window, forward, mol/(m²·s) of iron; anodic current +
|
|
128
|
+
const anodic = (j) => 2 * FARADAY * -iron.rates[0][j] - 4 * FARADAY * iron.rates[1][j];
|
|
129
|
+
console.log(`iron at ${(iron.V - sol.phi[0]).toFixed(3)} V against the water at the centre (SHE, with table μ°)`);
|
|
130
|
+
console.log(`net current on the iron: ${anodic(0).toFixed(2)} A/m² at the centre, ${anodic(last).toFixed(2)} A/m² at the rim`);
|
|
131
|
+
// The Evans diagram at the centre: each reaction's current with the iron's level moved, the water held.
|
|
132
|
+
const levels = [-0.55, -0.5, -0.45, -0.4].map((v) => v + sol.phi[0]);
|
|
133
|
+
const evans = polarization(dev, sol, { port: 'iron', x: 0 }, levels);
|
|
134
|
+
for (const rx of evans.reactions) console.log(`${rx.equation}: ${Array.from(rx.current, (i) => i.toFixed(2)).join(', ')} A/m²`);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Species can take an electrode's surface sites (`surface`, Langmuir coverages), and a reaction
|
|
138
|
+
with `bare: true` runs only on bare metal: a passive film (docs/device.md).
|
|
139
|
+
|
|
140
|
+
## a voltage clamp
|
|
141
|
+
|
|
142
|
+
A face's `gates` (each a fraction open, $`dx/dt = \alpha(V)(1 - x) - \beta(V)x`$ with $`V = \phi_{\mathrm{right}} - \phi_{\mathrm{left}}`$)
|
|
143
|
+
scale its links (`gates: { m: 3, h: 1 }`). `hodgkinHuxley({ T, linear: true })` (kit) writes the
|
|
144
|
+
squid axon's as Hodgkin and Huxley had them, conductances $`g(V - E)`$. Between two baths without a
|
|
145
|
+
reference species, $`V_{\mathrm{right}} - V_{\mathrm{left}}`$ is $`V_m`$, so a held step is their voltage clamp.
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
|
|
149
|
+
import { build, layer, bath, aqueous, hodgkinHuxley } from 'driftlet/kit';
|
|
150
|
+
|
|
151
|
+
const T = 279.45, VT = (GAS_CONSTANT * T) / FARADAY; // HH's 6.3 °C
|
|
152
|
+
const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
|
|
153
|
+
const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
|
|
154
|
+
water.species.push({ name: 'A-', z: -1, cRef: 1000 });
|
|
155
|
+
water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
|
|
156
|
+
const hh = hodgkinHuxley({ T, linear: true });
|
|
157
|
+
const step = -0.01; // from −65 mV to −10 mV at 1 ms
|
|
158
|
+
const dev = new Device(
|
|
159
|
+
build({
|
|
160
|
+
T,
|
|
161
|
+
library: [water],
|
|
162
|
+
stack: [
|
|
163
|
+
bath(out), // the outside, on the left
|
|
164
|
+
layer('water', 1e-6, { c0: out }),
|
|
165
|
+
{ phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
|
|
166
|
+
layer('water', 1e-6, { c0: inside }),
|
|
167
|
+
bath(inside, { t: [0, 1e-3, 1e-3, 1], values: [-0.065, -0.065, step, step] }),
|
|
168
|
+
],
|
|
169
|
+
grid: { hmin: 20e-9, hmax: 0.2e-6 },
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
dev.solve(); // the rest at −65 mV, the gates at their values there
|
|
173
|
+
const run = dev.advance(11e-3, { dtMax: 0.02e-3, probes: [{ interface: 0, species: 'Na+' }, { interface: 0, species: 'K+' }] });
|
|
174
|
+
const [na, k] = run.trace.probes.map((N) => N.map((x) => -FARADAY * x)); // outward currents, A/m²
|
|
175
|
+
const peak = na.indexOf(Math.min(...na)), EK = VT * Math.log(out['K+'] / inside['K+']);
|
|
176
|
+
console.log(`I_Na peaks ${((run.trace.t[peak] - 1e-3) * 1e3).toFixed(2)} ms into the step, ${(na[peak] * 0.1).toFixed(2)} mA/cm²`); // 0.73 ms, −1.48
|
|
177
|
+
console.log(`g_K after 10 ms: ${((k.at(-1) / (step - EK)) * 0.1).toFixed(2)} mS/cm²`); // 21.12: HH's 36 n∞⁴ is 21.5, which n nearly reaches by 10 ms
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## an action potential
|
|
181
|
+
|
|
182
|
+
Without `linear: true` (given `inside` and `outside`), `hodgkinHuxley` writes GHK permeabilities
|
|
183
|
+
matched to HH's conductances at −65 mV, which rectify as real channels do. A pump-less cell has no
|
|
184
|
+
steady rest (`solve()` finds Donnan), so start the transient from the concentrations given.
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
import { Device } from 'driftlet';
|
|
188
|
+
import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
|
|
189
|
+
|
|
190
|
+
const T = 279.45; // HH's 6.3 °C
|
|
191
|
+
const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
|
|
192
|
+
const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
|
|
193
|
+
water.species.push({ name: 'A-', z: -1, cRef: 1000 });
|
|
194
|
+
water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
|
|
195
|
+
const hh = hodgkinHuxley({ inside, outside: out, T }); // GHK channels; outside on the left
|
|
196
|
+
const axon = new Device(
|
|
197
|
+
build({
|
|
198
|
+
T,
|
|
199
|
+
library: [water],
|
|
200
|
+
stack: [
|
|
201
|
+
bath(out, 'Cl-'),
|
|
202
|
+
layer('water', 5e-6, { name: 'outside', c0: out }),
|
|
203
|
+
{ phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
|
|
204
|
+
layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
|
|
205
|
+
{ species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
|
|
206
|
+
],
|
|
207
|
+
// the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
|
|
208
|
+
ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
|
|
209
|
+
grid: { hmin: 50e-9, hmax: 1e-6 },
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
let peak = -1;
|
|
213
|
+
for (let k = 1; k <= 300; k++) {
|
|
214
|
+
const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
|
|
215
|
+
peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
|
|
216
|
+
}
|
|
217
|
+
console.log(`peak ${(peak * 1e3).toFixed(1)} mV`); // +12.6: GHK's Na⁺ current is limited by the 50 mM inside (linear: true gives +37.9, HH's own)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## an action potential travelling along an axon
|
|
221
|
+
|
|
222
|
+
Here x runs along the axon: the axoplasm is one region, and the membrane a port spanning it, a
|
|
223
|
+
capacitance and gated channels to an outside given as a `bath` (the port's V is then the
|
|
224
|
+
outside's $`\phi`$). `hodgkinHuxley({ T, area })` writes HH's channels for it, `area` being the membrane
|
|
225
|
+
per volume, 2/a. Cable theory isn't put in; it comes out of the ions' drift. `ports[k].Vm[j]` and
|
|
226
|
+
`ports[k].gates[name][j]` give the membrane at each node. A myelinated axon is regions, each with
|
|
227
|
+
its own port (an internode's just a small capacitance). Nothing warns of a grid too coarse for
|
|
228
|
+
the spike's front: the speed comes out 3% low on 2 mm cells, 0.2% on 0.5 mm, for a = 238 µm
|
|
229
|
+
(scale $`h`$ with $`\sqrt a`$). The stimulus is a current per volume, so a thinner axon needs it as $`1/a`$.
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
import { Device, FARADAY, GAS_CONSTANT } from 'driftlet';
|
|
233
|
+
import { hodgkinHuxley, injector, pulse } from 'driftlet/kit';
|
|
234
|
+
|
|
235
|
+
const T = 291.65, VT = (GAS_CONSTANT * T) / FARADAY, a = 238e-6; // 18.5 °C, a squid giant axon
|
|
236
|
+
const inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52.5, 'A-': 397.5 }; // mM
|
|
237
|
+
// Outside levels that put the Nernst potentials at HH's +50, −77 and −54.4 mV (leak as Cl⁻).
|
|
238
|
+
const outside = { 'Na+': 50 * Math.exp(0.05 / VT), 'K+': 400 * Math.exp(-0.077 / VT), 'Cl-': 52.5 * Math.exp(0.054387 / VT) };
|
|
239
|
+
// One D for the small ions, giving HH's 35.4 Ω·cm.
|
|
240
|
+
const D = 1 / 0.354 / ((FARADAY * FARADAY) / (GAS_CONSTANT * T)) / (50 + 400 + 52.5);
|
|
241
|
+
const sp = (d) => ({ D: d, mu0: 0, cRef: 1000 });
|
|
242
|
+
const sealed = { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' };
|
|
243
|
+
const hh = hodgkinHuxley({ T, area: 2 / a });
|
|
244
|
+
const axon = new Device({
|
|
245
|
+
T,
|
|
246
|
+
species: [{ name: 'Na+', z: 1 }, { name: 'K+', z: 1 }, { name: 'Cl-', z: -1 }, { name: 'A-', z: -1 }],
|
|
247
|
+
materials: { axoplasm: { epsr: 0, species: { 'Na+': sp(D), 'K+': sp(D), 'Cl-': sp(D), 'A-': sp(1e-12) } } },
|
|
248
|
+
regions: [{ name: 'axon', material: 'axoplasm', length: 0.06, c0: inside }],
|
|
249
|
+
contacts: { left: sealed, right: sealed },
|
|
250
|
+
ports: [
|
|
251
|
+
{ name: 'membrane', region: 'axon', V: 0, area: 2 / a, capacitance: { C: 0.01, zeroCharge: 0 }, bath: { c: outside }, gates: hh.gates, species: hh.species },
|
|
252
|
+
injector({ name: 'stim', region: 'axon', from: 0, to: 1e-3, species: 'K+', I: pulse({ start: 30e-3, width: 0.2e-3, amplitude: 30 }) }),
|
|
253
|
+
],
|
|
254
|
+
grid: { hmin: 2e-4, hmax: 2e-4 },
|
|
255
|
+
});
|
|
256
|
+
axon.advance(30e-3); // settle to rest (no pump: start from the concentrations, not solve())
|
|
257
|
+
const sol = axon.advance(34e-3, { dtMax: 5e-6, probes: [{ x: 0.02, quantity: 'phi' }, { x: 0.04, quantity: 'phi' }] });
|
|
258
|
+
const { t, probes } = sol.trace, at = (v) => t[v.findIndex((x) => x > 0)]; // (φ inside, the outside's held at 0)
|
|
259
|
+
console.log(`${(0.02 / (at(probes[1]) - at(probes[0]))).toFixed(1)} m/s`); // 18.8 here (finely resolved, 18.73; HH computed 18.8)
|
|
260
|
+
```
|