driftlet 0.11.4 → 0.12.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
@@ -44,7 +44,7 @@ import { bandDiagram } from 'driftlet/plot'; // a level diagram as an SVG str
44
44
  ```
45
45
 
46
46
  Or straight from a CDN in a page, pinned to a version:
47
- `https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/index.js` (and `src/kit.js`, `src/plot.js`).
47
+ `https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/index.js` (and `src/kit.js`, `src/plot.js`).
48
48
  TypeScript declarations ship in the package. Writing a demo with an LLM agent? Point it at
49
49
  [`llms.txt`](llms.txt).
50
50
 
@@ -141,6 +141,8 @@ them locally, serve the repository root (e.g. `python3 -m http.server`) and open
141
141
  | **Intercalation host**: cycling between cutoffs against the OCV | **Organic solar cell**: excitons diffusing to a donor/acceptor interface, against theory | **Perovskite hysteresis**: mobile ions and scan rate, against IonMonger |
142
142
  | [![charged nanochannel](demos/screenshots/channel.png)](https://markblundeberg.github.io/driftlet/demos/channel.html) | [![resting potential](demos/screenshots/cell.png)](https://markblundeberg.github.io/driftlet/demos/cell.html) | [![liquid junctions](demos/screenshots/junction.png)](https://markblundeberg.github.io/driftlet/demos/junction.html) |
143
143
  | **Charged nanochannel**: selectivity, overlapping Donnan layers and rectification, against Teorell–Meyer–Sievers | **Resting potential**: leaks and the Na⁺/K⁺ pump, against Mullins–Noda; the run-down to Donnan | **Liquid junctions**: a patch pipette's against Henderson, Planck and LJPcalc; a charged frit |
144
+ | [![action potential](demos/screenshots/axon.png)](https://markblundeberg.github.io/driftlet/demos/axon.html) | [![propagation](demos/screenshots/propagation.png)](https://markblundeberg.github.io/driftlet/demos/propagation.html) | [![myelin](demos/screenshots/myelin.png)](https://markblundeberg.github.io/driftlet/demos/myelin.html) |
145
+ | **Action potential**: Hodgkin and Huxley's gated channels, each ion's driving force through the spike, against the HH equations | **Propagation**: a spike travelling along an axon at HH's speed, out of the ions' drift; which ions carry it | **Myelin**: saltatory conduction, node to node, against a compartmental cable; a race with a bare axon |
144
146
 
145
147
  ## Is it for your problem?
146
148
 
@@ -156,7 +158,7 @@ The same equations go by different names in different fields. If your problem is
156
158
  | Double layers, colloids | Poisson–Boltzmann, Gouy–Chapman–Stern, Debye screening, crowding (Bikerman) | [double-layer demo](https://markblundeberg.github.io/driftlet/demos/double-layer.html), [`equilibrium`](test/equilibrium.test.js) test | Gouy–Chapman charge and profile, Kilic–Bazant–Ajdari |
157
159
  | Membranes, desalination | Donnan, ion exchange, liquid junctions, water dissociation | [membrane demo](https://markblundeberg.github.io/driftlet/demos/membrane.html), [`equilibrium`](test/equilibrium.test.js), [`neutral`](test/neutral.test.js) tests | Donnan partition, Planck EMF |
158
160
  | Solid-state ionics | mixed ionic–electronic conduction, defect chemistry, mobile ions | [`statistics`](test/statistics.test.js), [`reactions`](test/reactions.test.js) tests | mass action from standard potentials |
159
- | Biophysics, nanofluidics | charged nanochannels and pores, resting potentials, Goldman–Hodgkin–Katz, Nernst, Donnan, pumps and leaks | [charged-nanochannel](https://markblundeberg.github.io/driftlet/demos/channel.html), [resting-potential](https://markblundeberg.github.io/driftlet/demos/cell.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) (a patch pipette's) demos, [`pore`](test/pore.test.js) test; a membrane as a face: a capacitor with ion permeabilities, and the Na⁺/K⁺ pump as a reaction on it ([`membrane`](test/membrane.test.js) test) | GHK potential and current–voltage curve; a resolved lipid layer; Mullins–Noda; the pump's static head; Donnan |
161
+ | Biophysics, nanofluidics | charged nanochannels and pores, resting potentials, Goldman–Hodgkin–Katz, Nernst, Donnan, pumps and leaks | [charged-nanochannel](https://markblundeberg.github.io/driftlet/demos/channel.html), [resting-potential](https://markblundeberg.github.io/driftlet/demos/cell.html), [action-potential](https://markblundeberg.github.io/driftlet/demos/axon.html) and [liquid-junction](https://markblundeberg.github.io/driftlet/demos/junction.html) (a patch pipette's) demos, [`pore`](test/pore.test.js) test; a membrane as a face: a capacitor with ion permeabilities, and the Na⁺/K⁺ pump as a reaction on it ([`membrane`](test/membrane.test.js) test); voltage-gated channels with Hodgkin–Huxley kinetics, action potentials, and their propagation along an axon ([`gates`](test/gates.test.js), [`cable`](test/cable.test.js) tests) | GHK potential and current–voltage curve; a resolved lipid layer; Mullins–Noda; the pump's static head; Donnan; HH gating under voltage clamp; the space-clamped HH equations |
160
162
 
161
163
  The tests are worked setups, each with its analytic check, so they double as recipes. The
162
164
  [validation table](#validation) lists them all.
@@ -255,6 +257,8 @@ Every physics feature is tested against analytic results (`npm test`, node's bui
255
257
  | Schottky barrier (metal region \| n-Si) | surface density from the alignment; depletion charge | 5e-3; 2% |
256
258
  | Charged pore or nanochannel (resolved Donnan layers at each mouth) | conductance against salt, down to its plateau, as Teorell–Meyer–Sievers in a long pore (short ones short by end layers ∝ 1/L); the zero-current voltage across a salt gradient; a symmetric pore's I–V odd, rectification with charge on one side or bipolar | 1%; 5e-3 to 1e-4; 1e-9 |
257
259
  | Cell membrane (a capacitive face with permeabilities) | GHK resting potential (neutral or resolved solutions) and current–voltage curve; the same as a resolved 5 nm lipid layer; charging at I/C; with the Na⁺/K⁺ pump in a closed cell, Mullins–Noda, the static head 3Δμ̄_Na − 2Δμ̄_K = ΔG_ATP, and Donnan with the pump off | 1e-5 V; 3e-3; 2e-5 V; 1%; 1e-9 |
260
+ | Voltage-gated channels (Hodgkin–Huxley gates on a membrane face) | each gate at α/(α + β) under a voltage clamp, and relaxing at α + β after a step; a squid axon's action potential against the space-clamped HH equations with GHK currents; its impedance at rest (inductive, resonant) against the linearised equations | 1e-12; 2e-4; 1 mV; 5e-4 |
261
+ | A propagating action potential (a squid axon along x: gated membrane port, ions drifting in the axoplasm) | rest at HH's −65 mV and gate values; conduction velocity against the cable equation solved directly, and HH's 18.8 m/s; a myelinated axon's saltatory conduction, node by node, against a compartmental cable | 0.01 mV, 1e-4; 2e-3; 1%; 1 µs of ~25 per node |
258
262
  | Liquid junctions in mixtures | Henderson's formula against JPCalc's published values; driftlet's steady junction against Planck by shooting and the stationary Nernst–Planck codes LJPcalc and JLJP, a patch pipette's K-gluconate among them; a free-diffusion junction constant in time, between the two | 0.015 mV; 2e-4 and 0.02 mV; 5e-3 mV |
259
263
  | Liquid junction, open circuit | cell EMF 2t₊(RT/F) ln(c₁/c₂); Planck diffusion potential | 1e-4 |
260
264
  | Salt diffusion mode from a `c0` profile | c̄ + a·sin(πx/2L) decaying at π²D/4L², D = 2D₊D₋/(D₊ + D₋) | 2e-5 |
package/docs/device.md CHANGED
@@ -277,6 +277,68 @@ read from `sol.phi` (or a `'phi'` probe in a transient). A bath contact's termin
277
277
  its reference species' level instead, what an electrode reversible to that species would read,
278
278
  so between two different solutions it differs from $`\Delta\phi`$ by that species' Nernst term.
279
279
 
280
+ ### Voltage-gated channels
281
+
282
+ A permeability can be gated, as Hodgkin and Huxley's Na⁺ and K⁺ channels are: the face lists its
283
+ `gates`, each a fraction $`x`$ in [0, 1] with first-order kinetics in the voltage across the face,
284
+ $`V = \phi_{\mathrm{right}} - \phi_{\mathrm{left}}`$ at its two edge nodes,
285
+
286
+ ```math
287
+ \frac{dx}{dt} = \alpha(V)\,(1 - x) - \beta(V)\,x ,
288
+ ```
289
+
290
+ and a link names the gates that scale its $`P`$ (or a conductance link's $`G`$), with their exponents:
291
+ $`P\,m^3 h`$ for `{ m: 3, h: 1 }`. (A membrane along a region instead, an axon's, is a
292
+ [port](#a-membrane-through-a-window-gated-channels).)
293
+ Each of $`\alpha`$ and $`\beta`$ is one of NeuroML's three standard forms, with `rate` in 1/s and `midpoint`
294
+ and `scale` in volts:
295
+
296
+ | `type` | rate |
297
+ | --- | --- |
298
+ | `'exp'` | $`\mathtt{rate}\; e^{(V - \mathtt{midpoint})/\mathtt{scale}}`$ |
299
+ | `'sigmoid'` | $`\mathtt{rate} / (1 + e^{(\mathtt{midpoint} - V)/\mathtt{scale}})`$ |
300
+ | `'expLinear'` | $`\mathtt{rate}\; y/(1 - e^{-y})`$, $`y = (V - \mathtt{midpoint})/\mathtt{scale}`$ |
301
+
302
+ With the outside on the left, $`V`$ is the membrane potential $`\phi_{\mathrm{in}} - \phi_{\mathrm{out}}`$. Hodgkin and
303
+ Huxley's squid axon (at their 6.3 °C):
304
+
305
+ ```js nocheck
306
+ {
307
+ phi: { type: 'capacitive', C: 0.01 },
308
+ gates: {
309
+ m: { alpha: { type: 'expLinear', rate: 1000, midpoint: -0.040, scale: 0.010 }, beta: { type: 'exp', rate: 4000, midpoint: -0.065, scale: -0.018 } },
310
+ h: { alpha: { type: 'exp', rate: 70, midpoint: -0.065, scale: -0.020 }, beta: { type: 'sigmoid', rate: 1000, midpoint: -0.035, scale: 0.010 } },
311
+ n: { alpha: { type: 'expLinear', rate: 100, midpoint: -0.055, scale: 0.010 }, beta: { type: 'exp', rate: 125, midpoint: -0.065, scale: -0.080 } },
312
+ },
313
+ species: {
314
+ 'Na+': { type: 'permeability', P: 1.16e-6, gates: { m: 3, h: 1 } },
315
+ 'K+': { type: 'permeability', P: 1.33e-6, gates: { n: 4 } },
316
+ 'Cl-': { type: 'permeability', P: 6.4e-9 }, // the leak
317
+ 'A-': 'blocked',
318
+ },
319
+ }
320
+ ```
321
+
322
+ The gates scale permeabilities and nothing else: they hold no charge (the small gating current
323
+ of the channels' voltage sensors is left out, as Hodgkin and Huxley left it out), and each ion
324
+ still crosses by electrodiffusion down its own $`\bar\mu`$, so the dissipation stays positive whatever
325
+ they do. In steady state each gate is at $`\alpha/(\alpha + \beta)`$; each solution reports them as
326
+ `interfaces[f].gates` (`{ m, h, n }`) with the voltage they follow, `interfaces[f].V`.
327
+
328
+ Two things differ from the textbook equations. The currents are GHK's, which rectify: a channel's
329
+ conductance depends on the voltage, so a permeability matches a conductance $`g`$ at one voltage
330
+ only (above, the chord conductances at rest, −65 mV, for Hodgkin and Huxley's 120, 36 and 0.3
331
+ mS/cm²; matched at the reversal potentials instead, the rest depolarises itself and the axon
332
+ fires on its own). And a cell without a pump has no resting steady state: `solve()` finds its
333
+ Donnan equilibrium, every permeant ion level across the membrane, which the real cell reaches
334
+ only over hours. Start a transient from the concentrations given (it settles to rest within
335
+ milliseconds), or keep the gradients with the Na⁺/K⁺ pump on the same face. Started from the
336
+ same state, a squid axon's action potential follows the space-clamped Hodgkin–Huxley equations
337
+ with GHK currents to within 1 mV; and in small signals, with the membrane held at rest between
338
+ two baths, `impedance()` gives the gates' inductance that Cole measured (the impedance inductive
339
+ below ~50 Hz and resonant near 60 Hz), the linearised equations' to 5e-4
340
+ ([`gates`](../test/gates.test.js) test).
341
+
280
342
  ### Faces next to a conductor
281
343
 
282
344
  A conductor has no $`\phi`$, so a face beside it can't take a `dipole` or `step` alignment. Its $`\phi`$
@@ -432,6 +494,7 @@ any combination:
432
494
  | `reactions`, `area` | an [electrode spread through the window](#electrodes-spread-through-a-window) | a thin film on a metal, a crevice, a porous electrode |
433
495
  | `surface` | [species on that electrode's sites](#an-electrodes-surface-adsorbates-and-passive-films) | adsorbates, a passive film |
434
496
  | `capacitance`, `area` | [charge held across a capacitance](#a-capacitance-through-a-window) | a gate along a channel, a double layer |
497
+ | `gates` | [voltage-gated channels](#a-membrane-through-a-window-gated-channels) on its conductance links | an axon's membrane along it |
435
498
 
436
499
  ```js nocheck
437
500
  ports: [{
@@ -618,6 +681,44 @@ below threshold through saturation (second order in the grid); a porous electrod
618
681
  impedance $`\sqrt{r/y}\coth(L\sqrt{ry})`$, the double layer in series with the ions' chemical
619
682
  capacitance; and the gate's low-frequency impedance against $`dQ/dV_G`$ from steady states.
620
683
 
684
+ ### A membrane through a window: gated channels
685
+
686
+ A port with a capacitance can carry [gates](#voltage-gated-channels), as a face can: at each node of
687
+ its window, each gate is a fraction open with Hodgkin–Huxley kinetics in the voltage across the
688
+ port's capacitance there, $`V_m = \phi - (V - \mathtt{zeroCharge})`$, and its conductance links name
689
+ the gates that scale them (`G × m³h`). That's a membrane all along a region, between it and an
690
+ outside held by the port: an axon's, with x running along the axon. Per volume of axoplasm, a
691
+ cylinder of radius a has $`2/a`$ of membrane (`area`); held at `V: 0` with `zeroCharge: 0`, the port's
692
+ V is the outside's $`\phi`$, and a charged species' `offset` is its level there against that,
693
+ $`(\mu^\circ + RT\ln(c_{out}/c_{\mathrm{ref}}))/(zF)`$. A conductance link's current is then
694
+ $`G\,(V_{i,out} - V_i)`$, which is $`g\,a_m (E_i - V_m)`$: Hodgkin and Huxley's linear channel, with $`E_i`$ the
695
+ Nernst potential at the inside's concentration there.
696
+
697
+ ```js nocheck
698
+ ports: [{
699
+ name: 'membrane', region: 'axon', V: 0, terminal: 'K+', area: 2 / 238e-6, // a squid giant axon
700
+ capacitance: { C: 0.01, zeroCharge: 0 },
701
+ gates: hodgkinHuxley({ /* … */ T }).gates,
702
+ species: {
703
+ 'Na+': { type: 'conductance', G: 1200 * 2 / 238e-6, offset: offsetNa, gates: { m: 3, h: 1 } },
704
+ 'K+': { type: 'conductance', G: 360 * 2 / 238e-6, offset: offsetK, gates: { n: 4 } },
705
+ 'Cl-': { type: 'conductance', G: 3 * 2 / 238e-6, offset: offsetCl }, // the leak
706
+ },
707
+ }]
708
+ ```
709
+
710
+ With the axoplasm strictly neutral and the outside held (no resistance outside), the ions'
711
+ drift along x and the membrane's charging are cable theory, without its being put in: a squid
712
+ axon's action potential propagates at Hodgkin and Huxley's speed, 18.73 m/s for their 18.5 °C
713
+ axon (they computed 18.8 by hand), the cable equation's own to 2e-3
714
+ ([`cable`](../test/cable.test.js) test). A myelinated axon is regions of one axoplasm, each node
715
+ of Ranvier and each internode its own, with a port each: the nodes' gated, the internodes' a small
716
+ capacitance (myelin) and nothing else. The grid refines at every node's faces, and the spike jumps
717
+ node to node, arriving at each within 1 µs of a compartmental cable's prediction (the same test).
718
+ Each solution's `ports[k]` reports the gates at each node of the window, `gates[name][j]`,
719
+ and the voltage they follow, `Vm[j]`. A gated window can't overlap another port's gated or
720
+ surfaced one.
721
+
621
722
  ### What a solution reports
622
723
 
623
724
  Each solution's `ports[k]`, as the port has them:
@@ -630,6 +731,7 @@ Each solution's `ports[k]`, as the port has them:
630
731
  | `rates[q][j]` | reaction q's forward rate at node j, mol/(m²·s) of electrode (reaction first, then node) |
631
732
  | `coverage[name][j]`, `bare[j]` | with a surface: each species' coverage θ, and the bare fraction θ₀ to full precision |
632
733
  | `sigma[j]`, `charge` | with a capacitance: σ per area of electrode (C/m²), and the total the port's side holds |
734
+ | `gates[name][j]`, `Vm[j]` | with gates: each gate's fraction open, and the voltage across the capacitance that they follow (V) |
633
735
 
634
736
  The arrays are typed (`Float64Array`), so `Array.from` them before mapping to anything but
635
737
  numbers. The kit's [`polarization()`](kit.md#polarization-curves-an-evans-diagram) gives each reaction's rate at a spot of the
@@ -762,7 +864,9 @@ const now = dev.solution(); // snapshot of the current state
762
864
  `probes: [{ x, species, quantity, region }]` the trace also reads inside the device,
763
865
  as `trace.probes[k]` beside `trace.t`: a species' concentration (`quantity: 'c'`, the default,
764
866
  mol/m³) or species voltage (`'V'`), or $`\phi`$ itself (`'phi'`, no species), linearly between the
765
- nodes around `x` (at an interface, `region` picks the side). That's what a detector at `x` sees. Probes read at accepted steps, which grow
867
+ nodes around `x` (at an interface, `region` picks the side). That's what a detector at `x` sees.
868
+ At a face, `{ interface: f, species }` reads a species' flux through it (mol/(m²·s), toward +x,
869
+ as `interfaces[f].N`), and `{ interface: f, gate }` a gate's fraction open. Probes read at accepted steps, which grow
766
870
  as a transient slows, so give `dtMax` to resolve a signal in time.
767
871
  - `impedance(frequencies, { terminal, profiles })` solves the steady state, then linearises about
768
872
  it: $`Z(f) = \delta V/\delta I`$ in Ω·m² at one terminal (`'right'` by default), with I into the
package/docs/kit.md CHANGED
@@ -142,6 +142,18 @@ Concentrations are held, so these are the curves of that instant (for a mixed po
142
142
  kinetic picture). Away from the actual level the composition beside the electrode would change,
143
143
  which is the transport the full solve accounts for.
144
144
 
145
+ ## Channels
146
+
147
+ **`hodgkinHuxley({ inside, outside, T, g, leak, leakZ, at, names })`**: Hodgkin and Huxley's
148
+ squid-axon channels for a [membrane face](device.md#voltage-gated-channels) with the outside on its
149
+ left: `{ gates, species }`, the face's `gates` (m, h and n, their 1952 rates at 6.3 °C scaled to
150
+ `T` by Q₁₀ = 3) and links for Na⁺ (P·m³h), K⁺ (P·n⁴) and the leak (one ion, `leak`, of charge
151
+ `leakZ`: Cl⁻ by default). The currents are GHK's, which rectify, so each permeability is the one
152
+ whose chord conductance at `at` (−65 mV) is HH's `g` (120, 36 and 0.3 mS/cm²) at the
153
+ concentrations given. Spread it into the face beside its $`\phi`$ law, adding the other species'
154
+ links: `{ phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } }`.
155
+ `ghkCurrent(z, inside, outside, V, T)` is the GHK current (outward, A/m²) at unit permeability.
156
+
145
157
  ## Sources
146
158
 
147
159
  Helpers that write sources as plain data:
package/docs/numerics.md CHANGED
@@ -210,9 +210,16 @@ flux node's own rows are the interface laws:
210
210
  | species, blocked or absent on one side | $`N_f = 0`$ |
211
211
  | species, conductance | $`N_f - G V_T (\eta_L - \eta_R)/(z^2 F) = 0`$ |
212
212
  | face reaction $`k`$ | $`r_k - \mathrm{rate}_k(\mathrm{state}_L, \mathrm{state}_R) = 0`$, and each participant's edge node takes $`\nu \cdot r_k`$ |
213
+ | gate $`q`$ | $`(x_q - x_q^{\mathrm{old}})/dt - \alpha(V)(1 - x_q) + \beta(V)\,x_q = 0`$, $`V = V_T(\hat\phi_R - \hat\phi_L)`$; a gated permeability's $`P`$ takes $`\prod x^p`$ |
213
214
 
214
215
  Every row couples only neighbours, with no penalty terms. A face reaction's rate is an unknown
215
- of the face block, so the two edge nodes it couples meet only through the block between them.
216
+ of the face block, so the two edge nodes it couples meet only through the block between them;
217
+ so is a gate's fraction open, its row coupling it to the two edge nodes' $`\hat\phi`$, and the gated
218
+ fluxes to it, all within the face's block and its neighbours'. Newton's convergence and the time
219
+ step's error count a gate's change as they count a potential's in thermal units (1e-3 of $`m`$ is
220
+ 0.3% of $`m^3`$), and a gate is evaluated in [0, 1] where Newton's iterates stray past its ends. A
221
+ membrane port's gates are unknowns of each window node's block, after its surface's coverages,
222
+ with the same row in the voltage across the port's capacitance there.
216
223
  A species can take part in a reaction at a face and also cross it by a link (its flux slot)
217
224
  at the same time; where it exists on both sides, that link must be given explicitly. A continuity
218
225
  row has a zero diagonal block, but block Thomas still sees a non-singular block there once the
package/llms.txt CHANGED
@@ -14,9 +14,9 @@ docs/conventions.md (signs and levels) and docs/data.md (the data library).
14
14
  From a CDN, pin the version:
15
15
 
16
16
  ```js nocheck
17
- import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/index.js';
18
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/kit.js';
19
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/plot.js';
17
+ import { Device, units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/index.js';
18
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/kit.js';
19
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/plot.js';
20
20
  ```
21
21
 
22
22
  From npm: `driftlet`, `driftlet/kit`, `driftlet/plot`.
@@ -210,8 +210,50 @@ console.log(check(dev, sol).text); // per compartment: Na⁺ leaks into the cyto
210
210
  ```
211
211
 
212
212
  Between two baths instead (the inside one `bath(inside, 'Cl-', { I: 0 })`: held composition, no
213
- current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium. driftlet
214
- has no voltage-gated channels; a permeability can be changed with `set()` between steps.
213
+ current), V_m is GHK. Without the pump, a closed cell runs down to Donnan equilibrium.
214
+
215
+ ## Template: an action potential
216
+
217
+ Voltage-gated channels: a face's `gates` (each a fraction open with Hodgkin–Huxley kinetics,
218
+ dx/dt = α(V)(1 − x) − β(V)x, in V = φ_right − φ_left), and a permeability scaled by them
219
+ (`gates: { m: 3, h: 1 }`). `hodgkinHuxley()` in the kit writes the squid axon's: its gates, and
220
+ GHK permeabilities matched to HH's conductances at rest. A pump-less cell has no steady rest
221
+ (`solve()` finds Donnan), so start the transient from the concentrations given: the membrane
222
+ settles within milliseconds.
223
+
224
+ ```js
225
+ import { Device } from 'driftlet';
226
+ import { build, layer, bath, aqueous, injector, pulse, hodgkinHuxley } from 'driftlet/kit';
227
+
228
+ const T = 279.45; // HH's 6.3 °C
229
+ const out = { 'Na+': 440, 'K+': 20, 'Cl-': 450, 'A-': 10 }, inside = { 'Na+': 50, 'K+': 400, 'Cl-': 52, 'A-': 398 }; // squid, mM
230
+ const water = aqueous(['Na+', 'K+', 'Cl-'], { epsr: 0 });
231
+ water.species.push({ name: 'A-', z: -1, cRef: 1000 });
232
+ water.materials.water.species['A-'] = { D: 0.5e-9, mu0: 0 };
233
+ const hh = hodgkinHuxley({ inside, outside: out, T }); // outside on the left
234
+ const axon = new Device(
235
+ build({
236
+ T,
237
+ library: [water],
238
+ stack: [
239
+ bath(out, 'Cl-'),
240
+ layer('water', 5e-6, { name: 'outside', c0: out }),
241
+ { phi: { type: 'capacitive', C: 0.01 }, gates: hh.gates, species: { ...hh.species, 'A-': 'blocked' } },
242
+ layer('water', 20e-6, { name: 'cytoplasm', c0: inside }),
243
+ { species: { 'Na+': 'blocked', 'K+': 'blocked', 'Cl-': 'blocked', 'A-': 'blocked' }, phi: 'neutral' },
244
+ ],
245
+ // the stimulus: 10 µA/cm² of K⁺ for 1 ms at 20 ms, from a pipette in the cytoplasm
246
+ ports: [injector({ region: 'cytoplasm', from: 15e-6, to: 20e-6, species: 'K+', I: pulse({ start: 20e-3, width: 1e-3, amplitude: 0.1 }) })],
247
+ grid: { hmin: 50e-9, hmax: 1e-6 },
248
+ }),
249
+ );
250
+ let peak = -1;
251
+ for (let k = 1; k <= 300; k++) {
252
+ const sol = axon.advance(k * 1e-4); // 0.1 ms at a time
253
+ peak = Math.max(peak, sol.interfaces[0].V); // V_m, and sol.interfaces[0].gates = { m, h, n }
254
+ }
255
+ console.log(`peak ${(peak * 1e3).toFixed(1)} mV`); // ~ +12 mV: GHK's Na⁺ current is limited by the 50 mM inside
256
+ ```
215
257
 
216
258
  ## Template: an electrode in a stirred solution
217
259
 
@@ -423,9 +465,9 @@ solve), as long as it settles rather than ping-pongs.
423
465
  <span id="readout"></span>
424
466
  <div id="figure"></div>
425
467
  <script type="module">
426
- import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/index.js';
427
- import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/kit.js';
428
- import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.11.4/src/plot.js';
468
+ import { units } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/index.js';
469
+ import { build, layer, ohmic, semiconductor, live } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/kit.js';
470
+ import { bandDiagram } from 'https://cdn.jsdelivr.net/npm/driftlet@0.12.0/src/plot.js';
429
471
 
430
472
  const def = build({
431
473
  T: 300,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.11.4",
3
+ "version": "0.12.0",
4
4
  "description": "Small, dependency-free 1D drift–diffusion–reaction (Poisson–Nernst–Planck) solver for interactive web demos",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,73 @@
1
+ // driftlet/kit: voltage-gated channels as plain definition data. Hodgkin and Huxley's squid axon:
2
+ // its gates, and permeabilities for its Na⁺, K⁺ and leak channels, for a membrane face.
3
+
4
+ import { DeviceError } from './errors.js';
5
+ import { FARADAY, GAS_CONSTANT } from './constants.js';
6
+
7
+ const fail = (message) => {
8
+ throw new DeviceError(message);
9
+ };
10
+ const need = (ok, message) => ok || fail(message);
11
+ const num = (v) => typeof v === 'number' && Number.isFinite(v);
12
+
13
+ // Hodgkin and Huxley (1952), J. Physiol. 117, 500, at their 6.3 °C, in the modern convention
14
+ // (V = φ_in − φ_out, rest at −65 mV), in SI: rates in 1/s, voltages in V.
15
+ const mV = 1e-3;
16
+ const HH_GATES = {
17
+ m: { alpha: { type: 'expLinear', rate: 1000, midpoint: -40 * mV, scale: 10 * mV }, beta: { type: 'exp', rate: 4000, midpoint: -65 * mV, scale: -18 * mV } },
18
+ h: { alpha: { type: 'exp', rate: 70, midpoint: -65 * mV, scale: -20 * mV }, beta: { type: 'sigmoid', rate: 1000, midpoint: -35 * mV, scale: 10 * mV } },
19
+ n: { alpha: { type: 'expLinear', rate: 100, midpoint: -55 * mV, scale: 10 * mV }, beta: { type: 'exp', rate: 125, midpoint: -65 * mV, scale: -80 * mV } },
20
+ };
21
+ const HH_T = 273.15 + 6.3;
22
+
23
+ // The GHK current (outward, A/m²) of an ion at unit permeability, at voltage V (inside − outside).
24
+ export function ghkCurrent(z, inside, outside, V, T) {
25
+ const u = (z * V * FARADAY) / (GAS_CONSTANT * T);
26
+ if (Math.abs(u) < 1e-9) return z * FARADAY * (inside - outside);
27
+ return (z * FARADAY * u * (inside - outside * Math.exp(-u))) / -Math.expm1(-u);
28
+ }
29
+
30
+ /**
31
+ * Hodgkin and Huxley's squid-axon channels, for a membrane face with the outside on its left
32
+ * (so the gates' voltage, φ_right − φ_left, is the membrane potential): the face's `gates`, and
33
+ * `species` links for Na⁺ (P·m³h), K⁺ (P·n⁴) and the leak (ungated), to spread into the face
34
+ * beside its φ law, `{ phi: { type: 'capacitive', C: 0.01 }, ...hh }`, adding links (or blocks)
35
+ * for any other species.
36
+ *
37
+ * The currents are GHK's, which rectify, so a permeability matches a conductance at one voltage
38
+ * only: each is the one whose chord conductance at `at` (rest, −65 mV) is Hodgkin and Huxley's
39
+ * (`g`: 120, 36 and 0.3 mS/cm²), at the concentrations given. The leak is carried by one ion
40
+ * (`leak`, of charge `leakZ`: Cl⁻ by default, whose Nernst level in squid is near their leak
41
+ * reversal, −54 mV). The
42
+ * rates are theirs at 6.3 °C, scaled to `T` by Q₁₀ = 3.
43
+ * @param {{ inside: object, outside: object, T?: number, g?: { Na?: number, K?: number, leak?: number },
44
+ * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string } }} opts concentrations in
45
+ * mol/m³ by species name, K, S/m², V
46
+ * @returns {{ gates: object, species: object }}
47
+ */
48
+ export function hodgkinHuxley({ inside, outside, T = HH_T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {} } = {}) {
49
+ need(inside && outside && typeof inside === 'object' && typeof outside === 'object', 'hodgkinHuxley: give inside and outside concentrations (mol/m³) by species name');
50
+ need(num(T) && T > 0, `hodgkinHuxley: T must be a temperature in K, got ${T}`);
51
+ need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
52
+ const G = { Na: 1200, K: 360, leak: 3, ...g };
53
+ const Na = names.Na ?? 'Na+', K = names.K ?? 'K+';
54
+ const q10 = Math.exp(Math.log(3) * ((T - HH_T) / 10)); // Q₁₀ = 3
55
+ const scaled = (r) => ({ ...r, rate: r.rate * q10 });
56
+ const gates = Object.fromEntries(Object.entries(HH_GATES).map(([k, gt]) => [k, { alpha: scaled(gt.alpha), beta: scaled(gt.beta) }]));
57
+ const P = (name, z, gval, what) => {
58
+ need(num(inside[name]) && inside[name] > 0 && num(outside[name]) && outside[name] > 0, `hodgkinHuxley: give ${name}'s concentration inside and outside (mol/m³), for its ${what}`);
59
+ need(num(gval) && gval > 0, `hodgkinHuxley: g.${what} must be a conductance > 0 (S/m²), got ${gval}`);
60
+ const E = ((GAS_CONSTANT * T) / (z * FARADAY)) * Math.log(outside[name] / inside[name]);
61
+ need(Math.abs(at - E) > 1e-4, `hodgkinHuxley: ${name}'s Nernst level, ${(E * 1e3).toFixed(1)} mV, is at the calibration voltage; choose another 'at'`);
62
+ return (gval * (at - E)) / ghkCurrent(z, inside[name], outside[name], at, T);
63
+ };
64
+ need(Number.isInteger(leakZ) && leakZ !== 0, `hodgkinHuxley: leakZ must be the leak ion's charge number, got ${leakZ}`);
65
+ return {
66
+ gates,
67
+ species: {
68
+ [Na]: { type: 'permeability', P: P(Na, 1, G.Na, 'Na channels'), gates: { m: 3, h: 1 } },
69
+ [K]: { type: 'permeability', P: P(K, 1, G.K, 'K channels'), gates: { n: 4 } },
70
+ [leak]: { type: 'permeability', P: P(leak, leakZ, G.leak, 'leak') },
71
+ },
72
+ };
73
+ }
package/src/describe.js CHANGED
@@ -201,10 +201,12 @@ export function describe(def) {
201
201
  const rx = (idef.reactions ?? []).map((r) => (r.equation ?? equation(r.left, r.right)) + kinetics(r));
202
202
  const laws = species.flatMap((sp, i) => {
203
203
  const l = itf.links[i];
204
- return l.type === 'permeability' ? [`${sp.name} ${num(l.P)} m/s`] : l.type === 'conductance' ? [`${sp.name} ${num(l.G)} S/m²`] : [];
204
+ const gated = (l.gates ?? []).map(([q, p]) => itf.gates[q].name + (p > 1 ? `^${p}` : '')).join('·');
205
+ return l.type === 'permeability' ? [`${sp.name} ${num(l.P)} m/s${gated ? ` × ${gated}` : ''}`] : l.type === 'conductance' ? [`${sp.name} ${num(l.G)} S/m²`] : [];
205
206
  });
207
+ const gates = (itf.gates ?? []).map((g) => g.name);
206
208
  lines.push(
207
- ` ${L.name} | ${R.name}: ${law}${align}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}`,
209
+ ` ${L.name} | ${R.name}: ${law}${align}${laws.length ? `; crossing by a law: ${laws.join(', ')}` : ''}${blocked.length ? `; blocked: ${blocked.join(', ')}` : ''}${rx.length ? `; reactions: ${rx.join('; ')}` : ''}${gates.length ? `; gates (voltage-dependent, see interfaces[f].gates): ${gates.join(', ')}` : ''}`,
208
210
  );
209
211
  });
210
212
  }
package/src/device.js CHANGED
@@ -348,12 +348,12 @@ export function normalizeDevice(def) {
348
348
  need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
349
349
  port.nodes = Int32Array.from(nodes);
350
350
  });
351
- // A node's surface belongs to one electrode.
351
+ // A node's surface belongs to one electrode, and its gates to one membrane.
352
352
  const surfaced = new Int32Array(grid.nNodes).fill(-1);
353
353
  ports.forEach((port, k) => {
354
- if (port.surface.length === 0) return;
354
+ if (port.surface.length === 0 && port.gates.length === 0) return;
355
355
  for (const g of port.nodes) {
356
- need(surfaced[g] < 0, `ports[${k}]: its window overlaps ports[${surfaced[g]}]'s, and both have a surface; a spot of metal has one surface`);
356
+ need(surfaced[g] < 0, `ports[${k}]: its window overlaps ports[${surfaced[g]}]'s, and both have a surface or gates; a spot of metal has one surface, and of a membrane one set of channels`);
357
357
  surfaced[g] = k;
358
358
  }
359
359
  });
@@ -669,7 +669,7 @@ function normalizeGeometry(g) {
669
669
  // the window, `area` (m²/m³) of it per volume.
670
670
  function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
671
671
  need(isObject(pdef), `${path} must be an object`);
672
- fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance']);
672
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance', 'gates']);
673
673
  let r;
674
674
  if (Number.isInteger(pdef.region)) r = pdef.region;
675
675
  else r = regions.findIndex((reg) => reg.name === pdef.region);
@@ -690,6 +690,9 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
690
690
  const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface);
691
691
  surface.forEach((sp, s) => need(reactions.some((rx) => rx.part.some((p) => p.side === 2 && p.s === s)), `${path}.surface.${sp.name}: no reaction of the port makes or uses it`));
692
692
  const capacitance = portCapacitance(pdef, path, mat);
693
+ // Gates (a membrane's channels): they follow the voltage across the port's capacitance.
694
+ const gates = normalizeGates(pdef.gates, path, mat, mat);
695
+ need(gates.length === 0 || capacitance !== null, `${path}.gates: a gate follows the voltage across the port's capacitance (a membrane's), so give the port one`);
693
696
  need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
694
697
  const links = species.map(() => ({ type: 'blocked' }));
695
698
  for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
@@ -698,7 +701,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
698
701
  const i = speciesIndex.get(sname);
699
702
  const link = typeof raw === 'string' ? { type: raw } : raw;
700
703
  need(isObject(link) && ['blocked', 'equilibrium', 'conductance', 'exchange'].includes(link.type), `${lpath}.type must be one of blocked, equilibrium, conductance, exchange`);
701
- fields(link, lpath, LINK_FIELDS[link.type]);
704
+ fields(link, lpath, link.type === 'conductance' ? [...LINK_FIELDS.conductance, 'gates'] : LINK_FIELDS[link.type]);
702
705
  if (link.type === 'blocked') continue;
703
706
  need(mat.present[i], `${lpath}: '${sname}' is absent from ${reg.name} (material '${mat.name}')`);
704
707
  if (mat.conductor) need(i === mat.conductor.i, `${lpath}: a conductor exchanges only its carrier, ${species[mat.conductor.i].name}`);
@@ -718,6 +721,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
718
721
  // On a metal, G is a lumped conductance per area (S/m²), spread over its thickness.
719
722
  if (link.type === 'conductance') level.G = mat.conductor ? positive(link.G, `${lpath}.G (S/m², for a conductor)`) / reg.length : positive(link.G, `${lpath}.G (S/m³)`);
720
723
  if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
724
+ if (link.type === 'conductance') level.gates = gatedBy(link.gates, gates, lpath, path);
721
725
  links[i] = { type: link.type, ...level };
722
726
  }
723
727
  need(links.some((l) => l.type !== 'blocked') || reactions.length > 0 || capacitance !== null, `${path}.species: the port exchanges no species`);
@@ -731,7 +735,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
731
735
  area = typeof pdef.area === 'number' ? { value: positive(pdef.area, `${path}.area (m²/m³)`) } : profile(pdef.area, `${path}.area`, 'areas per volume (m²/m³)', (a) => a >= 0, 'an area per volume ≥ 0');
732
736
  } else need(pdef.area === undefined, `${path}.area: only an electrode (a port with reactions or a capacitance) has an area`);
733
737
  need(surface.length === 0 || reactions.length > 0, `${path}.surface: only an electrode (a port with reactions) has a surface`);
734
- return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance };
738
+ return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance, gates };
735
739
  }
736
740
 
737
741
  // A capacitance spread through the window: a gate along a channel, an electrode's double layer.
@@ -860,7 +864,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
860
864
  // macroscopic limit; the alignment then drops out. 'capacitive': a Helmholtz layer,
861
865
  // D = C (Δφ − dipole). Between two ε = 0 (strictly neutral) materials the default is neutral.
862
866
  if (matL.conductor || matR.conductor) return normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT);
863
- fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions']);
867
+ fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'gates']);
864
868
  const bothNeutral = (matL.epsr === 0 && matR.epsr === 0) || matL.phiFree || matR.phiFree;
865
869
  const rawPhi = idef.phi ?? (bothNeutral ? 'neutral' : 'pinned');
866
870
  const phi = typeof rawPhi === 'string' ? { type: rawPhi } : { ...rawPhi };
@@ -916,6 +920,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
916
920
  // blocked, an ohmic interface conductance G (S/m²), or a permeability P (m/s): electrodiffusion
917
921
  // through a thin membrane in a constant field, Goldman–Hodgkin–Katz.
918
922
  const links = defaultInterfaceLinks(matL, matR, species);
923
+ const gates = normalizeGates(idef.gates, where, matL, matR);
919
924
  if (idef.species !== undefined) {
920
925
  need(isObject(idef.species), `${where}.species must map species names to interface links`);
921
926
  for (const [sname, raw] of Object.entries(idef.species)) {
@@ -924,7 +929,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
924
929
  const i = speciesIndex.get(sname);
925
930
  const link = typeof raw === 'string' ? { type: raw } : raw;
926
931
  need(isObject(link) && INTERFACE_LINK_TYPES.has(link.type), `${lpath}.type must be one of ${[...INTERFACE_LINK_TYPES].join(', ')}`);
927
- fields(link, lpath, link.type === 'conductance' ? ['type', 'G'] : link.type === 'permeability' ? ['type', 'P'] : ['type']);
932
+ fields(link, lpath, link.type === 'conductance' ? ['type', 'G', 'gates'] : link.type === 'permeability' ? ['type', 'P', 'gates'] : ['type']);
928
933
  if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
929
934
  if (link.type === 'conductance') {
930
935
  need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
@@ -934,13 +939,52 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
934
939
  positive(link.P, `${lpath}.P (m/s)`);
935
940
  need(matL.modelOf[i] < 0 && matR.modelOf[i] < 0, `${lpath}: a permeability link needs ideal (dilute) statistics for '${sname}' on both sides`);
936
941
  }
937
- links[i] = { ...link };
942
+ // Gated: P (or G) × Π x_q^p over the face's gates (a channel open with probability m³h, say).
943
+ links[i] = { ...link, gates: gatedBy(link.gates, gates, lpath, where) };
938
944
  }
939
945
  }
940
946
 
941
947
  const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
942
948
  checkReactingLinks(reactions, idef, where, matL, matR, species);
943
- return { phi, dipole, sheetCharge, links, reactions, conductor: null };
949
+ return { phi, dipole, sheetCharge, links, reactions, gates, conductor: null };
950
+ }
951
+
952
+ // A face's gates (Hodgkin–Huxley): each a fraction x in [0, 1] with first-order kinetics in the
953
+ // voltage across the face, V = φ_right − φ_left,
954
+ // dx/dt = α(V) (1 − x) − β(V) x,
955
+ // α and β each one of NeuroML's three standard forms, with rate in 1/s and midpoint and scale in
956
+ // volts: 'exp', rate·e^((V − midpoint)/scale); 'sigmoid', rate/(1 + e^((midpoint − V)/scale));
957
+ // 'expLinear', rate·y/(1 − e^(−y)) with y = (V − midpoint)/scale. They scale permeabilities
958
+ // (a link's gates), and nothing else: a gate holds no charge and exchanges no free energy.
959
+ const GATE_RATE_TYPES = ['exp', 'sigmoid', 'expLinear'];
960
+ // A link's gates, { m: 3, h: 1 }, as [[gate index, exponent]], against the gates defined at `where`.
961
+ function gatedBy(raw, gates, lpath, where) {
962
+ if (raw === undefined) return [];
963
+ need(isObject(raw), `${lpath}.gates must map gate names to exponents, e.g. { m: 3, h: 1 }`);
964
+ return Object.entries(raw).map(([gname, p]) => {
965
+ const q = gates.findIndex((gt) => gt.name === gname);
966
+ need(q >= 0, `${lpath}.gates.${gname}: no such gate${gates.length ? ` (there are ${gates.map((gt) => gt.name).join(', ')})` : ` (define it in ${where}.gates)`}`);
967
+ need(Number.isInteger(p) && p >= 1 && p <= 8, `${lpath}.gates.${gname}: the exponent must be a whole number from 1 to 8, got ${JSON.stringify(p)}`);
968
+ return [q, p];
969
+ });
970
+ }
971
+ function normalizeGates(raw, where, matL, matR) {
972
+ if (raw === undefined) return [];
973
+ need(isObject(raw), `${where}.gates must map gate names to { alpha, beta }`);
974
+ need(!matL.phiFree && !matR.phiFree, `${where}.gates: a gate follows the voltage across the face, which needs φ on both sides`);
975
+ return Object.entries(raw).map(([name, gdef]) => {
976
+ const gpath = `${where}.gates.${name}`;
977
+ need(isObject(gdef), `${gpath} must be { alpha, beta }`);
978
+ fields(gdef, gpath, ['alpha', 'beta']);
979
+ const rate = (r, rpath) => {
980
+ need(isObject(r) && GATE_RATE_TYPES.includes(r.type), `${rpath} must be { type, rate, midpoint, scale } with type one of ${GATE_RATE_TYPES.join(', ')}`);
981
+ fields(r, rpath, ['type', 'rate', 'midpoint', 'scale']);
982
+ const scale = finite(r.scale, `${rpath}.scale (V)`);
983
+ need(scale !== 0, `${rpath}.scale (V) must not be zero`);
984
+ return { type: r.type, rate: positive(r.rate, `${rpath}.rate (1/s)`), midpoint: finite(r.midpoint, `${rpath}.midpoint (V)`), scale };
985
+ };
986
+ return { name, alpha: rate(gdef.alpha, `${gpath}.alpha`), beta: rate(gdef.beta, `${gpath}.beta`) };
987
+ });
944
988
  }
945
989
 
946
990
  // A conductor (a metal, or a fast ion conductor): only its one mobile carrier, whose single
@@ -1036,7 +1080,7 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
1036
1080
  }
1037
1081
  const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
1038
1082
  checkReactingLinks(reactions, idef, where, matL, matR, species);
1039
- return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, conductor: { side, i: metal.i } };
1083
+ return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
1040
1084
  }
1041
1085
 
1042
1086
  function defaultInterfaceLinks(matL, matR, species) {
package/src/kit.js CHANGED
@@ -10,6 +10,7 @@ export { parseEquation, stoichiometry, half, SHE, level } from './equation.js';
10
10
  export { traces, speciesRole, typeset } from './traces.js';
11
11
  export { live } from './live.js';
12
12
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from './sources.js';
13
+ export { hodgkinHuxley, ghkCurrent } from './channels.js';
13
14
  export { describe, unitWarnings } from './describe.js';
14
15
  export { recorder } from './recorder.js';
15
16
  export { henderson, planck } from './junction.js';
package/src/recorder.js CHANGED
@@ -15,7 +15,7 @@ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date
15
15
  * neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
16
16
  * `probes` if given) runs from the start through every accepted step of every call.
17
17
  * @param {import('./index.js').Device} device
18
- * @param {{ every?: number, times?: number[], probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
18
+ * @param {{ every?: number, times?: number[], probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number } | { interface: number, species?: string, gate?: string })[] }} [opts]
19
19
  */
20
20
  export function recorder(device, opts = {}) {
21
21
  const { every, times, probes } = opts;
package/src/solution.js CHANGED
@@ -118,6 +118,11 @@ export function makeSolution(solver, result = {}) {
118
118
  const sets = (side) => ['pinned', 'bulk'].includes(model.contacts[side].phi.type);
119
119
  out.sigma = Float64Array.from(port.nodes, (g) => ((g === 0 && sets('left')) || (g === last && sets('right')) ? 0 : Cs * (solver.termV[2 + k] - zeroCharge - phi[g])));
120
120
  }
121
+ if (port.gates.length > 0) {
122
+ // A membrane's gates at each node, and the voltage they follow, φ − (V − zeroCharge).
123
+ out.gates = Object.fromEntries(port.gates.map((gate, q) => [gate.name, Float64Array.from(port.nodes, (g) => u[solver.blockOfNode[g] * M + 1 + n + port.surface.length + q])]));
124
+ out.Vm = Float64Array.from(port.nodes, (g) => solver._portVoltage(k, g));
125
+ }
121
126
  if (port.area !== null) {
122
127
  out.x = Float64Array.from(port.nodes, (g) => model.grid.x[g]);
123
128
  out.area = Float64Array.from(solver.portArea[k]);
@@ -148,7 +153,13 @@ export function makeSolution(solver, result = {}) {
148
153
  const N = {};
149
154
  for (let i = 0; i < n; i++) N[species[i].name] = u[b * M + 1 + i];
150
155
  const rates = itf.reactions.map((_, k) => u[b * M + 1 + n + k]); // mol/(m²·s), forward
151
- return { left: model.regions[f].name, right: model.regions[f + 1].name, dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
156
+ const out = { left: model.regions[f].name, right: model.regions[f + 1].name, dipole: itf.dipole, sheetCharge: itf.sheetCharge, D: u[b * M], N, rates };
157
+ if (itf.gates.length > 0) {
158
+ // Each gate's fraction open, and the voltage across the face that drives them.
159
+ out.gates = Object.fromEntries(itf.gates.map((gate, q) => [gate.name, u[solver._gateSlot(f, q)]]));
160
+ out.V = solver._faceVoltage(f);
161
+ }
162
+ return out;
152
163
  });
153
164
 
154
165
  // Bulk reactions: each one's forward rate at every node (mol/(m³·s), NaN where it doesn't run),
package/src/solver.js CHANGED
@@ -261,17 +261,39 @@ export class SolverError extends Error {
261
261
  }
262
262
  }
263
263
 
264
+ // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
265
+ function gateRate(law, V) {
266
+ const { rate, midpoint, scale } = law;
267
+ if (law.type === 'exp') {
268
+ const v = rate * Math.exp((V - midpoint) / scale);
269
+ return [v, v / scale];
270
+ }
271
+ if (law.type === 'sigmoid') {
272
+ const e = Math.exp((midpoint - V) / scale), d = 1 + e;
273
+ return Number.isFinite(e) ? [rate / d, (rate * e) / (scale * d * d)] : [0, 0];
274
+ }
275
+ // expLinear: rate·y/(1 − e^−y), smooth through y = 0 (rate there) and → rate·y for large y
276
+ const y = (V - midpoint) / scale;
277
+ if (Math.abs(y) < 1e-6) return [rate * (1 + y / 2), rate / (2 * scale)];
278
+ if (y < -700) return [0, 0];
279
+ const em = -Math.expm1(-y), ey = Math.exp(-y); // 1 − e^−y, e^−y
280
+ return [(rate * y) / em, (rate * (em - y * ey)) / (em * em * scale)];
281
+ }
282
+
264
283
  export class Solver {
265
284
  constructor(model) {
266
285
  this.model = model;
267
286
  const { grid, species, materials, regions, contacts } = model;
268
287
  const n = species.length;
269
288
  // Slots per block: φ̂ (or D, or a conductor's segment flux J), one per species, and at a face
270
- // one per face reaction (its rate), as many as the busiest face has.
271
- const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length), 0);
289
+ // one per face reaction (its rate) and one per gate (its fraction open), as many as the
290
+ // busiest face has.
291
+ const nRx = model.interfaces.reduce((m, itf) => Math.max(m, itf.reactions.length + itf.gates.length), 0);
272
292
  // (and at an electrode port's nodes, one per surface species: its coverage's η)
273
293
  const nSurf = model.ports.reduce((m, port) => Math.max(m, port.surface.length), 0);
274
- const M = n + 1 + Math.max(nRx, nSurf);
294
+ // (and a membrane port's gates, after them: each one's fraction open)
295
+ const nPortSlots = model.ports.reduce((m, port) => Math.max(m, port.surface.length + port.gates.length), 0);
296
+ const M = n + 1 + Math.max(nRx, nPortSlots);
275
297
  const nNodes = grid.nNodes;
276
298
  const nFaces = regions.length - 1;
277
299
  const nB = nNodes + nFaces;
@@ -306,6 +328,20 @@ export class Solver {
306
328
  // The bare fraction θ₀, directly (1 − Σθ cancels as θ → 1); 1 where there's no surface.
307
329
  this.th0 = new Float64Array(nNodes).fill(1);
308
330
  this.th0Old = new Float64Array(nNodes).fill(1);
331
+ // Gates: each one's slot (at a face, after its reaction rates; at a membrane port's nodes, after
332
+ // the surface's coverages) and value at the step's start. Faces' first, then ports', node by
333
+ // node through each window.
334
+ this.gatePort = new Int32Array(nNodes).fill(-1);
335
+ model.ports.forEach((port, k) => port.gates.length > 0 && port.nodes.forEach((g) => (this.gatePort[g] = k)));
336
+ this.gateList = [
337
+ ...model.interfaces.flatMap((itf, f) => itf.gates.map((gate, q) => ({ f, q, gate, o: this.blockOfFace[f] * M + 1 + n + itf.reactions.length + q }))),
338
+ ...model.ports.flatMap((port, k) => Array.from(port.nodes).flatMap((g) => port.gates.map((gate, q) => ({ port: k, g, q, gate, o: this.blockOfNode[g] * M + 1 + n + port.surface.length + q })))),
339
+ ];
340
+ this.gateOld = new Float64Array(this.gateList.length);
341
+ this.gateIndex = new Int32Array(nFaces); // face f's first gate in gateList
342
+ let j = 0;
343
+ for (let f = 0; f < nFaces; j += model.interfaces[f].gates.length, f++) this.gateIndex[f] = j;
344
+ this.portGateIndex = model.ports.map((port) => ((j += port.nodes.length * port.gates.length), j - port.nodes.length * port.gates.length));
309
345
 
310
346
  // Per-node material data, flattened [g·n + i].
311
347
  this.present = new Uint8Array(nNodes * n);
@@ -574,12 +610,14 @@ export class Solver {
574
610
  for (let i = 0; i < n; i++) active[b * M + 1 + i] = this.present[g * n + i];
575
611
  const k = this.surfPort[g];
576
612
  if (k >= 0) for (let q = 0; q < model.ports[k].surface.length; q++) active[b * M + 1 + n + q] = 1;
613
+ const kg = this.gatePort[g], pg = model.ports[kg];
614
+ if (kg >= 0) for (let q = 0; q < pg.gates.length; q++) active[b * M + 1 + n + pg.surface.length + q] = 1;
577
615
  }
578
616
  model.interfaces.forEach((itf, f) => {
579
617
  const bf = this.blockOfFace[f];
580
618
  active[bf * M] = itf.phi.type === 'neutral' ? 0 : 1;
581
619
  for (let i = 0; i < n; i++) active[bf * M + 1 + i] = itf.links[i].type === 'blocked' ? 0 : 1;
582
- for (let k = 0; k < itf.reactions.length; k++) active[bf * M + 1 + n + k] = 1;
620
+ for (let k = 0; k < itf.reactions.length + itf.gates.length; k++) active[bf * M + 1 + n + k] = 1;
583
621
  });
584
622
  const sizes = new Int32Array(nB), loc = (this.loc = new Int32Array(nB * M).fill(-1));
585
623
  for (let b = 0; b < nB; b++) for (let r = 0; r < M; r++) if (active[b * M + r]) loc[b * M + r] = sizes[b]++;
@@ -1393,6 +1431,11 @@ export class Solver {
1393
1431
  });
1394
1432
  if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1395
1433
  }
1434
+ // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
1435
+ for (const gt of this.gateList) {
1436
+ const { gate, o } = gt, V = this._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
1437
+ u[o] = a + b > 0 ? a / (a + b) : 0;
1438
+ }
1396
1439
  // A floating electrode spread through a port (without a double layer) starts where its
1397
1440
  // reactions pass the current it's set (none, behind a resistance): at its mixed potential in
1398
1441
  // the start's composition, not level with a held terminal, which can be volts away and pass
@@ -1601,6 +1644,7 @@ export class Solver {
1601
1644
  const { grid, materials, regions } = model;
1602
1645
  sys.clear();
1603
1646
  res.fill(0);
1647
+ this.dtNow = dt;
1604
1648
  if (this.lin) this.lin.reset(1 / dt);
1605
1649
  this.computeConcentrations();
1606
1650
 
@@ -1752,7 +1796,7 @@ export class Solver {
1752
1796
  // A start-of-step net charge (round-off in a solved state) isn't carried over: the neutrality
1753
1797
  // row holds the new state neutral.
1754
1798
  _chargeRows(dt) {
1755
- const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY;
1799
+ const { n, M, res, c, cOld, z, sys, loc, present, termB, termC } = this, R = this.rix, F = FARADAY, live = this._liveTerms();
1756
1800
  const { A, B, C, sizes, offA, offB, offC } = sys, vol = this.model.grid.vol, last = this.nB - 1;
1757
1801
  for (const g of this.chargeNodes) {
1758
1802
  const b = this.blockOfNode[g], lb = b * M, m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < last ? sizes[b + 1] : 0;
@@ -1776,7 +1820,7 @@ export class Solver {
1776
1820
  for (let q = 0, o = offB[b] + lk * m, s = offB[b] + li * m; q < m; q++) B[o + q] += w * B[s + q];
1777
1821
  for (let q = 0, o = offC[b] + lk * mn, s = offC[b] + li * mn; q < mn; q++) C[o + q] += w * C[s + q];
1778
1822
  res[rk] += w * res[ri];
1779
- for (let t = 0; t < termB.length; t++) termB[t][rk] += w * termB[t][ri];
1823
+ for (const t of live) termB[t][rk] += w * termB[t][ri];
1780
1824
  if (this.lin) this.lin.combine(rk, ri, w);
1781
1825
  }
1782
1826
  // Columns: φ̂' = φ̂ with η fixed becomes φ̂ with η_i shifted by z_i, wherever node g's
@@ -1787,7 +1831,7 @@ export class Solver {
1787
1831
  for (let r = 0, o = offB[b]; r < m; r++) B[o + r * m + p] += zi * B[o + r * m + li];
1788
1832
  if (b > 0) for (let r = 0, mr = mp, o = offC[b - 1]; r < mr; r++) C[o + r * m + p] += zi * C[o + r * m + li];
1789
1833
  if (b < last) for (let r = 0, mr = mn, o = offA[b + 1]; r < mr; r++) A[o + r * m + p] += zi * A[o + r * m + li];
1790
- for (let t = 0; t < termC.length; t++) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1834
+ for (const t of live) termC[t][R[lb]] += zi * termC[t][R[lb + 1 + i]];
1791
1835
  }
1792
1836
  // Storage on the other charged balances, and neutrality: no φ̂' terms, exactly.
1793
1837
  const v = vol[g], oB = offB[b];
@@ -2036,14 +2080,21 @@ export class Solver {
2036
2080
  // materials). Between like solutions it's Goldman–Hodgkin–Katz's flux,
2037
2081
  // N = P·zu·(c_L − c_R e^{zu})/(e^{zu} − 1), u = (φ_R − φ_L)/V_T,
2038
2082
  // and it's exactly zero where μ̄ is level.
2039
- const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], P = itf.links[i].P;
2083
+ const gl = grid.regionEnd[f], gr = gl + 1, kl = gl * n + i, kr = gr * n + i, zi = z[i], link = itf.links[i];
2040
2084
  const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
2041
2085
  const d = zi * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
2042
2086
  const E = Math.expm1(-deta); // η_R − η_L
2087
+ // Gated: P × Π x^p, each x taken in [0, 1] (Newton's iterates can stray past its ends).
2088
+ const gated = link.gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
2089
+ const P = link.P * (gated.length ? this._open(gated, gx) : 1);
2043
2090
  const gBc = P * bernoulli(d) * this.c[kl];
2044
2091
  const dNdd = -P * bernoulliDerivative(d) * this.c[kl] * E;
2045
2092
  res[R[o]] = u[o] + gBc * E;
2046
2093
  this._j(bf, r, bf, r, 1);
2094
+ for (const [q, p] of gated) {
2095
+ const dO = this._dOpen(gated, q, p, gx);
2096
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, link.P * bernoulli(d) * this.c[kl] * E * dO);
2097
+ }
2047
2098
  this._j(bf, r, bL, r, -gBc);
2048
2099
  this._j(bf, r, bR, r, gBc * (E + 1));
2049
2100
  if (zi !== 0) {
@@ -2051,12 +2102,18 @@ export class Solver {
2051
2102
  this._j(bf, r, bR, 0, -zi * dNdd);
2052
2103
  }
2053
2104
  } else {
2054
- // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F)
2055
- const gG = (itf.links[i].G * VT) / (z[i] * z[i] * F);
2105
+ // conductance: J = G (V_L − V_R), V = V_T η / z ⇒ N = G V_T (η_L − η_R) / (z² F); gated,
2106
+ // G times Π x^p
2107
+ const gated = itf.links[i].gates ?? [], gx = (q) => u[this._gateSlot(f, q)];
2108
+ const gG0 = (itf.links[i].G * VT) / (z[i] * z[i] * F), gG = gG0 * (gated.length ? this._open(gated, gx) : 1);
2056
2109
  res[R[o]] = u[o] - gG * deta;
2057
2110
  this._j(bf, r, bf, r, 1);
2058
2111
  this._j(bf, r, bL, r, -gG);
2059
2112
  this._j(bf, r, bR, r, gG);
2113
+ for (const [q, p] of gated) {
2114
+ const dO = this._dOpen(gated, q, p, gx);
2115
+ if (dO !== 0) this._j(bf, r, bf, 1 + n + itf.reactions.length + q, -gG0 * deta * dO);
2116
+ }
2060
2117
  }
2061
2118
  res[R[bL * M + r]] += Af * u[o];
2062
2119
  this._j(bL, r, bf, r, Af);
@@ -2064,6 +2121,46 @@ export class Solver {
2064
2121
  this._j(bR, r, bf, r, -Af);
2065
2122
  }
2066
2123
  itf.reactions.forEach((rx, k) => this._faceReaction(rx, k, f, bf, bL, bR, Af));
2124
+ if (itf.gates.length > 0) this._faceGates(f, bf, bL, bR);
2125
+ }
2126
+
2127
+ // Full index of face f's gate q (its fraction open).
2128
+ _gateSlot(f, q) {
2129
+ return this.blockOfFace[f] * this.M + 1 + this.n + this.model.interfaces[f].reactions.length + q;
2130
+ }
2131
+
2132
+ // The voltage a gate follows: across its face, or across its port's capacitance at its node.
2133
+ _gateVoltage(gt) {
2134
+ return gt.port === undefined ? this._faceVoltage(gt.f) : this._portVoltage(gt.port, gt.g);
2135
+ }
2136
+
2137
+ // The voltage across port k's capacitance at node g, φ − (V − zeroCharge) (V): a membrane's,
2138
+ // inside (the region) minus outside.
2139
+ _portVoltage(k, g) {
2140
+ const { M, u, uLo, VT } = this, b = this.blockOfNode[g], port = this.model.ports[k];
2141
+ return VT * (u[b * M] + uLo[b * M]) - (this.termV[2 + k] - port.capacitance.zeroCharge);
2142
+ }
2143
+
2144
+ // The voltage across face f, φ_right − φ_left (V).
2145
+ _faceVoltage(f) {
2146
+ const { M, u, uLo, VT } = this, bf = this.blockOfFace[f], bL = bf - 1, bR = bf + 1;
2147
+ return VT * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M]));
2148
+ }
2149
+
2150
+ // Face f's gates, each a row (x − x_old)/dt = α(1 − x) − βx in the voltage across the face.
2151
+ _faceGates(f, bf, bL, bR) {
2152
+ const { res, VT } = this, R = this.rix, itf = this.model.interfaces[f], dt = this.dtNow;
2153
+ const V = this._faceVoltage(f), k0 = 1 + this.n + itf.reactions.length;
2154
+ itf.gates.forEach((gate, q) => {
2155
+ const o = this._gateSlot(f, q), x = this.u[o];
2156
+ const [a, da] = gateRate(gate.alpha, V), [b, db] = gateRate(gate.beta, V);
2157
+ const store = Number.isFinite(dt) ? 1 / dt : 0, j = this.gateIndex[f] + q;
2158
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - b * x);
2159
+ this._j(bf, k0 + q, bf, k0 + q, store + a + b);
2160
+ const dV = -(da * (1 - x) - db * x) * VT; // per unit φ̂_R, and minus that per unit φ̂_L
2161
+ this._j(bf, k0 + q, bR, 0, dV);
2162
+ this._j(bf, k0 + q, bL, 0, -dV);
2163
+ });
2067
2164
  }
2068
2165
 
2069
2166
  // Scharfetter–Gummel with non-ideal statistics. The excess ex = ζ − ln(c/c_ref) acts as an
@@ -2345,16 +2442,26 @@ export class Solver {
2345
2442
  }
2346
2443
  }
2347
2444
 
2445
+ // The terminals whose ∂res/∂V (termB) and ∂I/∂x (termC) are kept, exactly: the floating ones,
2446
+ // which the Newton solve borders on, or all of them (allTerminals: for the impedance, which reads
2447
+ // its terminal's). A held terminal's are otherwise left as they fall (written but not kept
2448
+ // consistent), which spares a device with dozens of ports (an axon's nodes of Ranvier) the cost
2449
+ // of carrying each one's two dense vectors through every assembly.
2450
+ _liveTerms() {
2451
+ if (this.allTerminals) return this._allTerms ?? (this._allTerms = this.terms.map((_, k) => k));
2452
+ return this.floating;
2453
+ }
2454
+
2348
2455
  // Every terminal's terms: its voltage held or floating, how the residual depends on it
2349
2456
  // (termB), its current into the device (termI) and how that depends on the state (termC).
2350
2457
  _terminals(dt) {
2351
2458
  this._refreshSources();
2352
- for (let k = 0; k < this.terms.length; k++) {
2459
+ for (const k of this._liveTerms()) {
2353
2460
  this.termB[k].fill(0);
2354
2461
  this.termC[k].fill(0);
2355
- this.termDI[k] = 0;
2356
- this.termI[k] = 0;
2357
2462
  }
2463
+ this.termDI.fill(0);
2464
+ this.termI.fill(0);
2358
2465
  this.qg.fill(0);
2359
2466
  this.qgSlope.fill(0);
2360
2467
  this.model.ports.forEach((port, k) => this._port(port, this.portFlux[k], 2 + k, dt, false));
@@ -2417,8 +2524,11 @@ export class Solver {
2417
2524
  B[R[o]] += -z[i] / VT;
2418
2525
  continue;
2419
2526
  }
2420
- // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η)
2421
- const kk = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2527
+ // conductance: s = G V_T (η_out − η)/(z² F); exchange: s = k (η_out − η). Gated, G times
2528
+ // Π x^p, the gates at this node (each taken in [0, 1]).
2529
+ const kk0 = link.type === 'conductance' ? (link.G * VT) / (z[i] * z[i] * F) : link.k;
2530
+ const gated = link.gates ?? [], q0 = 1 + n + port.surface.length;
2531
+ const open = gated.length ? this._open(gated, (q) => u[b * M + q0 + q]) : 1, kk = kk0 * open;
2422
2532
  res[R[o]] -= v * kk * deta;
2423
2533
  this._j(b, r, b, r, v * kk);
2424
2534
  flux[i] += v * kk * deta;
@@ -2427,6 +2537,12 @@ export class Solver {
2427
2537
  C[R[o]] += -zF * v * kk;
2428
2538
  this.termDI[k] += (zF * v * kk * z[i]) / VT;
2429
2539
  }
2540
+ for (const [q, p] of gated) {
2541
+ const d = this._dOpen(gated, q, p, (qq) => u[b * M + q0 + qq]);
2542
+ if (d === 0) continue;
2543
+ this._j(b, r, b, q0 + q, -v * kk0 * deta * d);
2544
+ if (z[i] !== 0) C[R[b * M + q0 + q]] += zF * v * kk0 * deta * d;
2545
+ }
2430
2546
  }
2431
2547
  }
2432
2548
  if (holds) {
@@ -2435,6 +2551,7 @@ export class Solver {
2435
2551
  }
2436
2552
  port.reactions.forEach((rx, x) => this._portReaction(port, rx, this.portArea[k - 2], this.portRates[k - 2][x], flux, k));
2437
2553
  if (port.capacitance) this._portCapacitance(port, k, dt);
2554
+ if (port.gates.length > 0) this._portGates(port, k, dt);
2438
2555
  if (port.surface.length > 0 && Number.isFinite(dt)) this._surfaceStorage(port, dt);
2439
2556
  }
2440
2557
 
@@ -2503,6 +2620,38 @@ export class Solver {
2503
2620
  // on the port's side, so each node's charge balance (its φ row) gains vol·a·σ, and the port
2504
2621
  // passes the charging current d(Σ vol·a·σ)/dt (none in a steady state). An end node whose φ its
2505
2622
  // contact sets is left to the contact.
2623
+ // A membrane port's gates, at each node of its window: (x − x_old)/dt = α(1 − x) − βx in the
2624
+ // voltage across its capacitance there, φ − (V − zeroCharge).
2625
+ _portGates(port, k, dt) {
2626
+ const { n, res, VT } = this, R = this.rix, B = this.termB[k], store = Number.isFinite(dt) ? 1 / dt : 0, q0 = 1 + n + port.surface.length;
2627
+ port.nodes.forEach((g, w) => {
2628
+ const b = this.blockOfNode[g], V = this._portVoltage(k - 2, g);
2629
+ port.gates.forEach((gate, q) => {
2630
+ const o = b * this.M + q0 + q, x = this.u[o], j = this.portGateIndex[k - 2] + w * port.gates.length + q;
2631
+ const [a, da] = gateRate(gate.alpha, V), [bb, db] = gateRate(gate.beta, V);
2632
+ res[R[o]] = store * (x - this.gateOld[j]) - (a * (1 - x) - bb * x);
2633
+ this._j(b, q0 + q, b, q0 + q, store + a + bb);
2634
+ const s = -(da * (1 - x) - db * x); // ∂/∂V_m, with V_m = V_T φ̂ − V + zeroCharge
2635
+ this._j(b, q0 + q, b, 0, s * VT);
2636
+ B[R[o]] += -s;
2637
+ });
2638
+ });
2639
+ }
2640
+
2641
+ // Π x^p over a link's gates (each x taken in [0, 1]), and its derivative in gate q.
2642
+ _open(gated, x) {
2643
+ let open = 1;
2644
+ for (const [q, p] of gated) open *= powi(Math.min(1, Math.max(0, x(q))), p);
2645
+ return open;
2646
+ }
2647
+ _dOpen(gated, q, p, x) {
2648
+ const xq = x(q);
2649
+ if (!(xq > 0 && xq < 1)) return 0;
2650
+ let others = 1;
2651
+ for (const [q2, p2] of gated) if (q2 !== q) others *= powi(Math.min(1, Math.max(0, x(q2))), p2);
2652
+ return p * powi(xq, p - 1) * others;
2653
+ }
2654
+
2506
2655
  _portCapacitance(port, k, dt) {
2507
2656
  const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2508
2657
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
@@ -2738,7 +2887,7 @@ export class Solver {
2738
2887
 
2739
2888
  // Scale every row by its largest Jacobian entry (in place, residual too).
2740
2889
  _equilibrate() {
2741
- const { nB, sys, res, termB } = this;
2890
+ const { nB, sys, res, termB } = this, live = this._liveTerms();
2742
2891
  const { A, B, C, sizes, offA, offB, offC, offX } = sys;
2743
2892
  for (let b = 0; b < nB; b++) {
2744
2893
  const m = sizes[b], mp = b > 0 ? sizes[b - 1] : 0, mn = b < nB - 1 ? sizes[b + 1] : 0;
@@ -2758,7 +2907,7 @@ export class Solver {
2758
2907
  for (let k = 0; k < m; k++) B[ob + k] *= s;
2759
2908
  for (let k = 0; k < mn; k++) C[oc + k] *= s;
2760
2909
  res[offX[b] + r] *= s;
2761
- for (let k = 0; k < termB.length; k++) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
2910
+ for (const k of live) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
2762
2911
  }
2763
2912
  }
2764
2913
  }
@@ -2883,6 +3032,7 @@ export class Solver {
2883
3032
  const k = this.surfPort[g]; // an electrode surface's coverages, as η
2884
3033
  if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(delta[R[b * M + 1 + n + q]]));
2885
3034
  }
3035
+ for (const gt of this.gateList) mx = Math.max(mx, Math.abs(delta[R[gt.o]])); // a gate's fraction open
2886
3036
  return mx;
2887
3037
  }
2888
3038
 
@@ -3104,6 +3254,7 @@ export class Solver {
3104
3254
  this.termVPrev.set(this.termV);
3105
3255
  this.computeConcentrations();
3106
3256
  const cN = Float64Array.from(this.c), thN = Float64Array.from(this.th), th0N = Float64Array.from(this.th0);
3257
+ const gN = Float64Array.from(this.gateList, (gt) => this.u[gt.o] + this.uLo[gt.o]);
3107
3258
  // Contact displacement before the step: as it was at the end of the previous step (under
3108
3259
  // the parameters then), so a gate-voltage change shows up as displacement current.
3109
3260
  if (!this.contactDEnd || !this.portQEnd) {
@@ -3124,6 +3275,7 @@ export class Solver {
3124
3275
  for (let k = 0; k < cOld.length; k++) cOld[k] = b1 * cN[k] - b2 * prev.c[k];
3125
3276
  for (let k = 0; k < thN.length; k++) this.thOld[k] = b1 * thN[k] - b2 * prev.th[k];
3126
3277
  for (let k = 0; k < th0N.length; k++) this.th0Old[k] = b1 * th0N[k] - b2 * prev.th0[k];
3278
+ for (let k = 0; k < gN.length; k++) this.gateOld[k] = b1 * gN[k] - b2 * prev.g[k];
3127
3279
  this.contactDStart = { left: b1 * DN.left - b2 * prev.D.left, right: b1 * DN.right - b2 * prev.D.right };
3128
3280
  this.portQStart = QN.map((q, k) => b1 * q - b2 * prev.Q[k]);
3129
3281
  dtEff = dt / a0;
@@ -3131,6 +3283,7 @@ export class Solver {
3131
3283
  this.cOld.set(cN);
3132
3284
  this.thOld.set(thN);
3133
3285
  this.th0Old.set(th0N);
3286
+ this.gateOld.set(gN);
3134
3287
  this.contactDStart = DN;
3135
3288
  this.portQStart = QN;
3136
3289
  }
@@ -3162,7 +3315,7 @@ export class Solver {
3162
3315
  }
3163
3316
  result.bdf = bdf;
3164
3317
  if (result.converged) {
3165
- this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, D: DN, Q: QN });
3318
+ this.history.unshift({ t: this.time, dt, u: Float64Array.from(this.uPrev), c: cN, th: thN, th0: th0N, g: gN, D: DN, Q: QN });
3166
3319
  if (this.history.length > 3) this.history.length = 3;
3167
3320
  this.time = tEnd;
3168
3321
  this.lastDt = dtEff;
@@ -3363,9 +3516,22 @@ export class Solver {
3363
3516
  const fail = (m) => {
3364
3517
  throw new DeviceError(m);
3365
3518
  };
3366
- if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }');
3519
+ if (!Array.isArray(probes)) fail('advance: probes must be an array of { x, species, quantity, region }, or { interface, species } / { interface, gate }');
3367
3520
  const plan = probes.map((p, k) => {
3368
3521
  const path = `advance: probes[${k}]`;
3522
+ if (p?.interface !== undefined) {
3523
+ // At a face: a species' flux through it (mol/(m²·s), toward +x), or a gate's fraction open.
3524
+ const f = p.interface, itf = this.model.interfaces[f];
3525
+ if (!(Number.isInteger(f) && itf)) fail(`${path}.interface: no interface ${JSON.stringify(f)} (they're numbered from 0, left to right)`);
3526
+ if (p.gate !== undefined) {
3527
+ const q = itf.gates.findIndex((gt) => gt.name === p.gate);
3528
+ if (q < 0) fail(`${path}.gate: interfaces[${f}] has no gate ${JSON.stringify(p.gate)}${itf.gates.length ? ` (it has ${itf.gates.map((gt) => gt.name).join(', ')})` : ''}`);
3529
+ return { o: this._gateSlot(f, q) };
3530
+ }
3531
+ const i = species.findIndex((sp) => sp.name === p.species);
3532
+ if (i < 0) fail(`${path}.species: no species ${JSON.stringify(p.species)}`);
3533
+ return { o: this.blockOfFace[f] * this.M + 1 + i };
3534
+ }
3369
3535
  const quantity = p?.quantity ?? 'c';
3370
3536
  if (quantity !== 'c' && quantity !== 'V' && quantity !== 'phi') fail(`${path}.quantity must be 'c' (mol/m³), 'V' (the species voltage) or 'phi' (φ, V)`);
3371
3537
  const i = quantity === 'phi' ? -1 : species.findIndex((sp) => sp.name === p?.species);
@@ -3397,7 +3563,7 @@ export class Solver {
3397
3563
  };
3398
3564
  return {
3399
3565
  out,
3400
- read: () => plan.map((q) => (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1)),
3566
+ read: () => plan.map((q) => (q.o !== undefined ? (this.loc[q.o] < 0 ? 0 : this.u[q.o]) : (1 - q.w) * at(q, q.g) + q.w * at(q, q.g + 1))),
3401
3567
  push: (values) => values.forEach((v, k) => out[k].push(v)),
3402
3568
  };
3403
3569
  }
@@ -3415,6 +3581,7 @@ export class Solver {
3415
3581
  const k = this.surfPort[g]; // an electrode surface's coverages, as η
3416
3582
  if (k >= 0) for (let q = 0; q < this.model.ports[k].surface.length; q++) mx = Math.max(mx, Math.abs(u[b * M + 1 + n + q] - ref[b * M + 1 + n + q]));
3417
3583
  }
3584
+ for (const { o } of this.gateList) mx = Math.max(mx, Math.abs(u[o] - ref[o])); // (a fraction: 1e-3 of m³ is 0.3% of P)
3418
3585
  return mx;
3419
3586
  }
3420
3587
 
@@ -3458,6 +3625,7 @@ export class Solver {
3458
3625
  cOld: Float64Array.from(this.cOld),
3459
3626
  thOld: Float64Array.from(this.thOld),
3460
3627
  th0Old: Float64Array.from(this.th0Old),
3628
+ gateOld: Float64Array.from(this.gateOld),
3461
3629
  time: this.time,
3462
3630
  lastDt: this.lastDt,
3463
3631
  atSteady: this.atSteady,
@@ -3480,6 +3648,7 @@ export class Solver {
3480
3648
  this.cOld.set(s.cOld);
3481
3649
  this.thOld.set(s.thOld);
3482
3650
  this.th0Old.set(s.th0Old);
3651
+ this.gateOld.set(s.gateOld);
3483
3652
  this.time = s.time;
3484
3653
  this.lastDt = s.lastDt;
3485
3654
  this.atSteady = s.atSteady;
@@ -3515,7 +3684,17 @@ export class Solver {
3515
3684
  * Z = δV/δI with I into the device (positive real part for a passive device).
3516
3685
  * @param {ArrayLike<number>} frequencies Hz
3517
3686
  */
3518
- impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3687
+ impedance(frequencies, opts) {
3688
+ const was = this.allTerminals;
3689
+ this.allTerminals = true; // (it reads its terminal's ∂res/∂V and ∂I/∂x)
3690
+ try {
3691
+ return this._impedance(frequencies, opts);
3692
+ } finally {
3693
+ this.allTerminals = was;
3694
+ }
3695
+ }
3696
+
3697
+ _impedance(frequencies, { profiles = false, terminal = 'right' } = {}) {
3519
3698
  const { M, nB, sys, VT } = this;
3520
3699
  const kT = this.terms.findIndex((t) => t.name === terminal);
3521
3700
  if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
@@ -3527,6 +3706,7 @@ export class Solver {
3527
3706
  this.cOld.set(this.c);
3528
3707
  this.thOld.set(this.th);
3529
3708
  this.th0Old.set(this.th0);
3709
+ this.gateList.forEach((gt, j) => (this.gateOld[j] = this.u[gt.o]));
3530
3710
  this.assemble(Infinity);
3531
3711
  this.contactDStart = { ...this.contactD };
3532
3712
  this.portQStart = Float64Array.from(this.portQ);
@@ -3955,6 +4135,7 @@ export class Solver {
3955
4135
  // Jacobian are left partial (the next assemble() starts afresh). Concentrations must be current.
3956
4136
  _assembleBookkeeping(dt) {
3957
4137
  const { model } = this;
4138
+ this.dtNow = dt;
3958
4139
  const { grid, materials, regions } = model;
3959
4140
  this.res.fill(0);
3960
4141
  for (let r = 0; r < regions.length; r++) {
@@ -0,0 +1,39 @@
1
+ export function ghkCurrent(z: any, inside: any, outside: any, V: any, T: any): number;
2
+ /**
3
+ * Hodgkin and Huxley's squid-axon channels, for a membrane face with the outside on its left
4
+ * (so the gates' voltage, φ_right − φ_left, is the membrane potential): the face's `gates`, and
5
+ * `species` links for Na⁺ (P·m³h), K⁺ (P·n⁴) and the leak (ungated), to spread into the face
6
+ * beside its φ law, `{ phi: { type: 'capacitive', C: 0.01 }, ...hh }`, adding links (or blocks)
7
+ * for any other species.
8
+ *
9
+ * The currents are GHK's, which rectify, so a permeability matches a conductance at one voltage
10
+ * only: each is the one whose chord conductance at `at` (rest, −65 mV) is Hodgkin and Huxley's
11
+ * (`g`: 120, 36 and 0.3 mS/cm²), at the concentrations given. The leak is carried by one ion
12
+ * (`leak`, of charge `leakZ`: Cl⁻ by default, whose Nernst level in squid is near their leak
13
+ * reversal, −54 mV). The
14
+ * rates are theirs at 6.3 °C, scaled to `T` by Q₁₀ = 3.
15
+ * @param {{ inside: object, outside: object, T?: number, g?: { Na?: number, K?: number, leak?: number },
16
+ * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string } }} opts concentrations in
17
+ * mol/m³ by species name, K, S/m², V
18
+ * @returns {{ gates: object, species: object }}
19
+ */
20
+ export function hodgkinHuxley({ inside, outside, T, g, leak, leakZ, at, names }?: {
21
+ inside: object;
22
+ outside: object;
23
+ T?: number;
24
+ g?: {
25
+ Na?: number;
26
+ K?: number;
27
+ leak?: number;
28
+ };
29
+ leak?: string;
30
+ leakZ?: number;
31
+ at?: number;
32
+ names?: {
33
+ Na?: string;
34
+ K?: string;
35
+ };
36
+ }): {
37
+ gates: object;
38
+ species: object;
39
+ };
package/types/kit.d.ts CHANGED
@@ -61,6 +61,7 @@ export { build, combine, layer, ohmic, bath } from "./stack.js";
61
61
  export { parseEquation, stoichiometry, half, SHE, level } from "./equation.js";
62
62
  export { traces, speciesRole, typeset } from "./traces.js";
63
63
  export { pulse, square, triangle, ramp, injector, recombination, photogeneration } from "./sources.js";
64
+ export { hodgkinHuxley, ghkCurrent } from "./channels.js";
64
65
  export { describe, unitWarnings } from "./describe.js";
65
66
  export { henderson, planck } from "./junction.js";
66
67
  export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from "./devices.js";
@@ -4,17 +4,21 @@
4
4
  * neither, at the end of each `advance()` call. The trace (`t`, `current`, `voltage`, and
5
5
  * `probes` if given) runs from the start through every accepted step of every call.
6
6
  * @param {import('./index.js').Device} device
7
- * @param {{ every?: number, times?: number[], probes?: { x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number }[] }} [opts]
7
+ * @param {{ every?: number, times?: number[], probes?: ({ x: number, species?: string, quantity?: 'c' | 'V' | 'phi', region?: string | number } | { interface: number, species?: string, gate?: string })[] }} [opts]
8
8
  */
9
9
  export function recorder(device: import("./index.js").Device, opts?: {
10
10
  every?: number;
11
11
  times?: number[];
12
- probes?: {
12
+ probes?: ({
13
13
  x: number;
14
14
  species?: string;
15
15
  quantity?: "c" | "V" | "phi";
16
16
  region?: string | number;
17
- }[];
17
+ } | {
18
+ interface: number;
19
+ species?: string;
20
+ gate?: string;
21
+ })[];
18
22
  }): {
19
23
  advance: (tEnd: number, aopts?: object) => import("./types.js").Solution;
20
24
  frame: (t: number) => import("./types.js").Solution;
package/types/solver.d.ts CHANGED
@@ -23,6 +23,11 @@ export class Solver {
23
23
  thOld: Float64Array<ArrayBuffer>;
24
24
  th0: Float64Array<any>;
25
25
  th0Old: Float64Array<any>;
26
+ gatePort: Int32Array<any>;
27
+ gateList: any[];
28
+ gateOld: Float64Array<ArrayBuffer>;
29
+ gateIndex: Int32Array<ArrayBuffer>;
30
+ portGateIndex: any;
26
31
  present: Uint8Array<ArrayBuffer>;
27
32
  cRef: Float64Array<ArrayBuffer>;
28
33
  mu0hat: Float64Array<ArrayBuffer>;
@@ -181,6 +186,7 @@ export class Solver {
181
186
  _j(rb: any, rs: any, cb: any, cs: any, v: any): void;
182
187
  /** Assemble residual and Jacobian for a backward-Euler step of size dt. */
183
188
  assemble(dt: any): void;
189
+ dtNow: any;
184
190
  transformed: any;
185
191
  _nodeConductor(g: any, dt: any): void;
186
192
  _nodesDilute(g0: any, g1: any, dt: any): void;
@@ -192,6 +198,11 @@ export class Solver {
192
198
  _segmentDisplacement(s: any, bL: any, bR: any, mat: any, h: any): void;
193
199
  _segmentsDilute(g0: any, g1: any, region: any, mat: any, vel: any): void;
194
200
  _face(f: any): void;
201
+ _gateSlot(f: any, q: any): any;
202
+ _gateVoltage(gt: any): number;
203
+ _portVoltage(k: any, g: any): number;
204
+ _faceVoltage(f: any): number;
205
+ _faceGates(f: any, bf: any, bL: any, bR: any): void;
195
206
  _segmentConcentrated(s: any, bL: any, bR: any, mat: any, h: any, vel: any): void;
196
207
  _segmentConductor(s: any, bL: any, bR: any, mat: any, h: any): void;
197
208
  _segmentMixing(s: any, bL: any, bR: any, Dm: any, h: any, mat: any): void;
@@ -207,11 +218,16 @@ export class Solver {
207
218
  Ca: number;
208
219
  };
209
220
  _faceReaction(rx: any, k: any, f: any, bf: any, bL: any, bR: any, Af: any): void;
221
+ _liveTerms(): any;
222
+ _allTerms: any;
210
223
  _terminals(dt: any): void;
211
224
  _circuit(k: any): void;
212
225
  _captureRow(b: any, s: any, w: any, into: any): any;
213
226
  _port(port: any, flux: any, k: any, dt: any, holds: any): void;
214
227
  _portReaction(port: any, rx: any, area: any, rates: any, flux: any, k: any): void;
228
+ _portGates(port: any, k: any, dt: any): void;
229
+ _open(gated: any, x: any): number;
230
+ _dOpen(gated: any, q: any, p: any, x: any): number;
215
231
  _portCapacitance(port: any, k: any, dt: any): void;
216
232
  _surfaceStorage(port: any, dt: any): void;
217
233
  _contact(side: any, dt: any): void;
@@ -310,6 +326,7 @@ export class Solver {
310
326
  cOld: Float64Array<ArrayBuffer>;
311
327
  thOld: Float64Array<ArrayBuffer>;
312
328
  th0Old: Float64Array<ArrayBuffer>;
329
+ gateOld: Float64Array<ArrayBuffer>;
313
330
  time: number;
314
331
  lastDt: number;
315
332
  atSteady: boolean;
@@ -343,14 +360,22 @@ export class Solver {
343
360
  * Z = δV/δI with I into the device (positive real part for a passive device).
344
361
  * @param {ArrayLike<number>} frequencies Hz
345
362
  */
346
- impedance(frequencies: ArrayLike<number>, { profiles, terminal }?: {
363
+ impedance(frequencies: ArrayLike<number>, opts: any): {
364
+ f: Float64Array<ArrayBuffer>;
365
+ Z: {
366
+ re: Float64Array<any>;
367
+ im: Float64Array<any>;
368
+ };
369
+ };
370
+ allTerminals: any;
371
+ _impedance(frequencies: any, { profiles, terminal }?: {
347
372
  profiles?: boolean;
348
373
  terminal?: string;
349
374
  }): {
350
375
  f: Float64Array<ArrayBuffer>;
351
376
  Z: {
352
- re: Float64Array<ArrayBuffer>;
353
- im: Float64Array<ArrayBuffer>;
377
+ re: Float64Array<any>;
378
+ im: Float64Array<any>;
354
379
  };
355
380
  };
356
381
  _regionShift(yr: any, yi: any): Float64Array<any>[];