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 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.x.** Released on npm and used by the demos, but the API may still change between
44
- minor versions.
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.16.4/src/index.js` (and `src/kit.js`, `src/plot.js`).
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). With `profiles: true`, each
973
- frequency also returns complex profiles of $`\delta\phi`$, $`\delta\bar\mu`$ and $`\delta c`$ per unit
974
- excitation.
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
- | `gates.left/right` | charge on a gate or Stern plate, where the contact is capacitive |
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
@@ -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 (adding −0.5 (RT/F) ln(x/(1−x)) there passes).
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
+ ```