driftlet 0.11.4 → 0.13.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 +50 -74
- package/docs/device.md +143 -9
- package/docs/kit.md +25 -2
- package/docs/numerics.md +8 -1
- package/docs/reliability.md +60 -3
- package/docs/statistics.md +10 -0
- package/llms.txt +572 -259
- package/package.json +1 -1
- package/src/channels.js +96 -0
- package/src/describe.js +4 -2
- package/src/device.js +108 -21
- package/src/index.js +6 -1
- package/src/kit.js +1 -0
- package/src/recorder.js +1 -1
- package/src/solution.js +28 -3
- package/src/solver.js +292 -67
- package/src/stack.js +6 -4
- package/src/types.js +2 -1
- package/types/channels.d.ts +50 -0
- package/types/kit.d.ts +1 -0
- package/types/recorder.d.ts +7 -3
- package/types/solver.d.ts +32 -5
- package/types/stack.d.ts +6 -5
- package/types/types.d.ts +5 -0
package/package.json
CHANGED
package/src/channels.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
*
|
|
44
|
+
* Or with `linear: true`, Hodgkin and Huxley's own linear channels on the face: conductance links,
|
|
45
|
+
* G = g, gated the same, whose currents are g(V − E) with E each ion's Nernst level at the
|
|
46
|
+
* concentrations on either side (so `inside`, `outside` and `at` aren't needed).
|
|
47
|
+
*
|
|
48
|
+
* Or, given `area` (membrane per volume of the region, 2/a for an axon of radius a), for a membrane
|
|
49
|
+
* port along a region: links that are their linear conductances, G = g × area per volume, gated the
|
|
50
|
+
* same, each ion's reversal potential following from the port's bath and the concentrations inside
|
|
51
|
+
* (so `inside`, `outside` and `at` aren't needed).
|
|
52
|
+
* @param {{ inside: object, outside: object, T?: number, g?: { Na?: number, K?: number, leak?: number },
|
|
53
|
+
* leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string }, linear?: boolean, area?: number }} opts concentrations in
|
|
54
|
+
* mol/m³ by species name, K, S/m², V
|
|
55
|
+
* @returns {{ gates: object, species: object }}
|
|
56
|
+
*/
|
|
57
|
+
export function hodgkinHuxley({ inside, outside, T = HH_T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {}, linear = false, area } = {}) {
|
|
58
|
+
need(num(T) && T > 0, `hodgkinHuxley: T must be a temperature in K, got ${T}`);
|
|
59
|
+
need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
|
|
60
|
+
const G = { Na: 1200, K: 360, leak: 3, ...g };
|
|
61
|
+
const Na = names.Na ?? 'Na+', K = names.K ?? 'K+';
|
|
62
|
+
const q10 = Math.exp(Math.log(3) * ((T - HH_T) / 10)); // Q₁₀ = 3
|
|
63
|
+
const scaled = (r) => ({ ...r, rate: r.rate * q10 });
|
|
64
|
+
const gates = Object.fromEntries(Object.entries(HH_GATES).map(([k, gt]) => [k, { alpha: scaled(gt.alpha), beta: scaled(gt.beta) }]));
|
|
65
|
+
need(typeof linear === 'boolean', `hodgkinHuxley: linear must be true or false, got ${linear}`);
|
|
66
|
+
if (area !== undefined || linear) {
|
|
67
|
+
need(area === undefined || (num(area) && area > 0), `hodgkinHuxley: area must be the membrane per volume (m²/m³; 2/a for an axon of radius a), got ${area}`);
|
|
68
|
+
for (const [k, v] of Object.entries(G)) need(num(v) && v > 0, `hodgkinHuxley: g.${k} must be a conductance > 0 (S/m²), got ${v}`);
|
|
69
|
+
const per = area ?? 1; // per volume along a port, or per area on a face
|
|
70
|
+
return {
|
|
71
|
+
gates,
|
|
72
|
+
species: {
|
|
73
|
+
[Na]: { type: 'conductance', G: G.Na * per, gates: { m: 3, h: 1 } },
|
|
74
|
+
[K]: { type: 'conductance', G: G.K * per, gates: { n: 4 } },
|
|
75
|
+
[leak]: { type: 'conductance', G: G.leak * per },
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
need(inside && outside && typeof inside === 'object' && typeof outside === 'object', 'hodgkinHuxley: give inside and outside concentrations (mol/m³) by species name (or, for a membrane port, its area)');
|
|
80
|
+
const P = (name, z, gval, what) => {
|
|
81
|
+
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}`);
|
|
82
|
+
need(num(gval) && gval > 0, `hodgkinHuxley: g.${what} must be a conductance > 0 (S/m²), got ${gval}`);
|
|
83
|
+
const E = ((GAS_CONSTANT * T) / (z * FARADAY)) * Math.log(outside[name] / inside[name]);
|
|
84
|
+
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'`);
|
|
85
|
+
return (gval * (at - E)) / ghkCurrent(z, inside[name], outside[name], at, T);
|
|
86
|
+
};
|
|
87
|
+
need(Number.isInteger(leakZ) && leakZ !== 0, `hodgkinHuxley: leakZ must be the leak ion's charge number, got ${leakZ}`);
|
|
88
|
+
return {
|
|
89
|
+
gates,
|
|
90
|
+
species: {
|
|
91
|
+
[Na]: { type: 'permeability', P: P(Na, 1, G.Na, 'Na channels'), gates: { m: 3, h: 1 } },
|
|
92
|
+
[K]: { type: 'permeability', P: P(K, 1, G.K, 'K channels'), gates: { n: 4 } },
|
|
93
|
+
[leak]: { type: 'permeability', P: P(leak, leakZ, G.leak, 'leak') },
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
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
|
-
|
|
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²${gated ? ` × ${gated}` : ''}`] : [];
|
|
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
|
@@ -15,7 +15,7 @@ import { stoichiometry as equationStoichiometry, faceSides, parseEquation } from
|
|
|
15
15
|
export { DeviceError };
|
|
16
16
|
|
|
17
17
|
// Contacts use the same laws as internal faces: the outside is a phase with known levels.
|
|
18
|
-
const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange']);
|
|
18
|
+
const SPECIES_LINK_TYPES = new Set(['blocked', 'equilibrium', 'conductance', 'exchange', 'velocity']);
|
|
19
19
|
const INTERFACE_LINK_TYPES = new Set(['equilibrium', 'blocked', 'conductance', 'permeability']);
|
|
20
20
|
const PHI_LINK_TYPES = new Set(['bulk', 'neutral', 'capacitive', 'pinned']);
|
|
21
21
|
const GRID_FIELDS = ['hmin', 'hmax', 'ratio', 'minCells'];
|
|
@@ -25,6 +25,7 @@ const LINK_FIELDS = {
|
|
|
25
25
|
equilibrium: ['type', 'offset', 'mu'],
|
|
26
26
|
conductance: ['type', 'G', 'offset'],
|
|
27
27
|
exchange: ['type', 'k', 'mu'],
|
|
28
|
+
velocity: ['type', 'v', 'offset', 'mu'],
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
@@ -348,12 +349,12 @@ export function normalizeDevice(def) {
|
|
|
348
349
|
need(nodes.length > 0, `ports[${k}]: the window [${port.from}, ${port.to}] m holds no grid node; widen it or refine the grid`);
|
|
349
350
|
port.nodes = Int32Array.from(nodes);
|
|
350
351
|
});
|
|
351
|
-
// A node's surface belongs to one electrode.
|
|
352
|
+
// A node's surface belongs to one electrode, and its gates to one membrane.
|
|
352
353
|
const surfaced = new Int32Array(grid.nNodes).fill(-1);
|
|
353
354
|
ports.forEach((port, k) => {
|
|
354
|
-
if (port.surface.length === 0) return;
|
|
355
|
+
if (port.surface.length === 0 && port.gates.length === 0) return;
|
|
355
356
|
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`);
|
|
357
|
+
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
358
|
surfaced[g] = k;
|
|
358
359
|
}
|
|
359
360
|
});
|
|
@@ -669,7 +670,7 @@ function normalizeGeometry(g) {
|
|
|
669
670
|
// the window, `area` (m²/m³) of it per volume.
|
|
670
671
|
function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
|
|
671
672
|
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']);
|
|
673
|
+
fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance', 'gates', 'bath']);
|
|
673
674
|
let r;
|
|
674
675
|
if (Number.isInteger(pdef.region)) r = pdef.region;
|
|
675
676
|
else r = regions.findIndex((reg) => reg.name === pdef.region);
|
|
@@ -690,7 +691,28 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
|
|
|
690
691
|
const reactions = portReactions(pdef, path, mat, terminal, species, speciesIndex, RT, surface);
|
|
691
692
|
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
693
|
const capacitance = portCapacitance(pdef, path, mat);
|
|
694
|
+
// Gates (a membrane's channels): they follow the voltage across the port's capacitance.
|
|
695
|
+
const gates = normalizeGates(pdef.gates, path, mat, mat, "the port's capacitance");
|
|
696
|
+
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
697
|
need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
|
|
698
|
+
// A bath: the outside's composition. The port's V is then the outside's φ, and each linked
|
|
699
|
+
// species' level there, against it, follows from the composition through the window's
|
|
700
|
+
// statistics, (μ° + RT ζ)/(zF) (or μ for a neutral one), unless its link gives one. Only the
|
|
701
|
+
// linked species' levels are taken, so it needn't list (or balance) the rest.
|
|
702
|
+
let bathLevel = null;
|
|
703
|
+
if (pdef.bath !== undefined) {
|
|
704
|
+
need(isObject(pdef.bath) && isObject(pdef.bath.c), `${path}.bath must be { c: { species: concentration } }`);
|
|
705
|
+
fields(pdef.bath, `${path}.bath`, ['c']);
|
|
706
|
+
const cb = new Float64Array(species.length);
|
|
707
|
+
for (const [sname, v] of Object.entries(pdef.bath.c)) {
|
|
708
|
+
need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
|
|
709
|
+
const i = speciesIndex.get(sname);
|
|
710
|
+
need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${reg.name} (material '${mat.name}'), so its level outside means nothing here`);
|
|
711
|
+
cb[i] = positive(v, `${path}.bath.c.${sname}`);
|
|
712
|
+
}
|
|
713
|
+
const zb = bathZeta(mat, cb, reg.background);
|
|
714
|
+
bathLevel = (i) => (cb[i] > 0 ? mat.mu0[i] + RT * zb[i] : undefined); // μ̄ − zFφ outside
|
|
715
|
+
}
|
|
694
716
|
const links = species.map(() => ({ type: 'blocked' }));
|
|
695
717
|
for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
|
|
696
718
|
const lpath = `${path}.species.${sname}`;
|
|
@@ -698,26 +720,28 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
|
|
|
698
720
|
const i = speciesIndex.get(sname);
|
|
699
721
|
const link = typeof raw === 'string' ? { type: raw } : raw;
|
|
700
722
|
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]);
|
|
723
|
+
fields(link, lpath, link.type === 'conductance' ? [...LINK_FIELDS.conductance, 'gates'] : LINK_FIELDS[link.type]);
|
|
702
724
|
if (link.type === 'blocked') continue;
|
|
703
725
|
need(mat.present[i], `${lpath}: '${sname}' is absent from ${reg.name} (material '${mat.name}')`);
|
|
704
726
|
if (mat.conductor) need(i === mat.conductor.i, `${lpath}: a conductor exchanges only its carrier, ${species[mat.conductor.i].name}`);
|
|
705
727
|
const z = species[i].z;
|
|
706
728
|
// The outside level: an offset from V for charged species, an absolute μ for neutral ones.
|
|
707
729
|
let level;
|
|
730
|
+
const fromBath = bathLevel?.(i);
|
|
708
731
|
if (z === 0) {
|
|
709
732
|
need(link.type !== 'conductance', `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
|
|
710
|
-
level = { mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
|
|
733
|
+
level = { mu: finite(link.mu ?? fromBath, `${lpath}.mu (the outside μ, J/mol${bathLevel ? `; or give '${sname}' in the bath` : ''})`) };
|
|
711
734
|
} else {
|
|
712
735
|
need(link.type !== 'exchange', `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
|
|
713
736
|
need(link.mu === undefined, `${lpath}: a charged species is held by an offset from the port voltage, not by mu`);
|
|
714
|
-
const offset = link.offset ?? (terminal === i ? 0 : undefined);
|
|
715
|
-
need(offset !== undefined, `${lpath}.offset: give V_i − V (V); only the terminal species defaults to 0`);
|
|
737
|
+
const offset = link.offset ?? (fromBath !== undefined ? fromBath / (z * FARADAY) : !bathLevel && terminal === i ? 0 : undefined);
|
|
738
|
+
need(offset !== undefined, bathLevel ? `${lpath}: give '${sname}' in the bath (or an offset, V_i − V)` : `${lpath}.offset: give V_i − V (V); only the terminal species defaults to 0 (or give the port a bath)`);
|
|
716
739
|
level = { offset: finite(offset, `${lpath}.offset`) };
|
|
717
740
|
}
|
|
718
741
|
// On a metal, G is a lumped conductance per area (S/m²), spread over its thickness.
|
|
719
742
|
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
743
|
if (link.type === 'exchange') level.k = positive(link.k, `${lpath}.k (mol/(m³·s))`);
|
|
744
|
+
if (link.type === 'conductance') level.gates = gatedBy(link.gates, gates, lpath, path);
|
|
721
745
|
links[i] = { type: link.type, ...level };
|
|
722
746
|
}
|
|
723
747
|
need(links.some((l) => l.type !== 'blocked') || reactions.length > 0 || capacitance !== null, `${path}.species: the port exchanges no species`);
|
|
@@ -731,7 +755,7 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
|
|
|
731
755
|
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
756
|
} else need(pdef.area === undefined, `${path}.area: only an electrode (a port with reactions or a capacitance) has an area`);
|
|
733
757
|
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 };
|
|
758
|
+
return { name: pdef.name ?? path, region: r, from, to, span: reg.length, drive, terminal, species: links, passes, reactions, area, surface, capacitance, gates };
|
|
735
759
|
}
|
|
736
760
|
|
|
737
761
|
// A capacitance spread through the window: a gate along a channel, an electrode's double layer.
|
|
@@ -860,7 +884,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
860
884
|
// macroscopic limit; the alignment then drops out. 'capacitive': a Helmholtz layer,
|
|
861
885
|
// D = C (Δφ − dipole). Between two ε = 0 (strictly neutral) materials the default is neutral.
|
|
862
886
|
if (matL.conductor || matR.conductor) return normalizeConductorInterface(idef, where, matL, matR, species, speciesIndex, RT);
|
|
863
|
-
fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions']);
|
|
887
|
+
fields(idef, where, ['phi', 'dipole', 'step', 'sheetCharge', 'species', 'reactions', 'gates']);
|
|
864
888
|
const bothNeutral = (matL.epsr === 0 && matR.epsr === 0) || matL.phiFree || matR.phiFree;
|
|
865
889
|
const rawPhi = idef.phi ?? (bothNeutral ? 'neutral' : 'pinned');
|
|
866
890
|
const phi = typeof rawPhi === 'string' ? { type: rawPhi } : { ...rawPhi };
|
|
@@ -916,6 +940,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
916
940
|
// blocked, an ohmic interface conductance G (S/m²), or a permeability P (m/s): electrodiffusion
|
|
917
941
|
// through a thin membrane in a constant field, Goldman–Hodgkin–Katz.
|
|
918
942
|
const links = defaultInterfaceLinks(matL, matR, species);
|
|
943
|
+
const gates = normalizeGates(idef.gates, where, matL, matR);
|
|
919
944
|
if (idef.species !== undefined) {
|
|
920
945
|
need(isObject(idef.species), `${where}.species must map species names to interface links`);
|
|
921
946
|
for (const [sname, raw] of Object.entries(idef.species)) {
|
|
@@ -924,7 +949,7 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
924
949
|
const i = speciesIndex.get(sname);
|
|
925
950
|
const link = typeof raw === 'string' ? { type: raw } : raw;
|
|
926
951
|
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']);
|
|
952
|
+
fields(link, lpath, link.type === 'conductance' ? ['type', 'G', 'gates'] : link.type === 'permeability' ? ['type', 'P', 'gates'] : ['type']);
|
|
928
953
|
if (link.type !== 'blocked') need(matL.present[i] && matR.present[i], `${lpath}: '${sname}' must be present on both sides`);
|
|
929
954
|
if (link.type === 'conductance') {
|
|
930
955
|
need(species[i].z !== 0, `${lpath}: a conductance link needs a charged species`);
|
|
@@ -934,13 +959,52 @@ function normalizeInterface(idef, f, regions, materials, species, speciesIndex,
|
|
|
934
959
|
positive(link.P, `${lpath}.P (m/s)`);
|
|
935
960
|
need(matL.modelOf[i] < 0 && matR.modelOf[i] < 0, `${lpath}: a permeability link needs ideal (dilute) statistics for '${sname}' on both sides`);
|
|
936
961
|
}
|
|
937
|
-
|
|
962
|
+
// Gated: P (or G) × Π x_q^p over the face's gates (a channel open with probability m³h, say).
|
|
963
|
+
links[i] = { ...link, gates: gatedBy(link.gates, gates, lpath, where) };
|
|
938
964
|
}
|
|
939
965
|
}
|
|
940
966
|
|
|
941
967
|
const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
|
|
942
968
|
checkReactingLinks(reactions, idef, where, matL, matR, species);
|
|
943
|
-
return { phi, dipole, sheetCharge, links, reactions, conductor: null };
|
|
969
|
+
return { phi, dipole, sheetCharge, links, reactions, gates, conductor: null };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// A face's gates (Hodgkin–Huxley): each a fraction x in [0, 1] with first-order kinetics in the
|
|
973
|
+
// voltage across the face, V = φ_right − φ_left,
|
|
974
|
+
// dx/dt = α(V) (1 − x) − β(V) x,
|
|
975
|
+
// α and β each one of NeuroML's three standard forms, with rate in 1/s and midpoint and scale in
|
|
976
|
+
// volts: 'exp', rate·e^((V − midpoint)/scale); 'sigmoid', rate/(1 + e^((midpoint − V)/scale));
|
|
977
|
+
// 'expLinear', rate·y/(1 − e^(−y)) with y = (V − midpoint)/scale. They scale permeabilities
|
|
978
|
+
// (a link's gates), and nothing else: a gate holds no charge and exchanges no free energy.
|
|
979
|
+
const GATE_RATE_TYPES = ['exp', 'sigmoid', 'expLinear'];
|
|
980
|
+
// A link's gates, { m: 3, h: 1 }, as [[gate index, exponent]], against the gates defined at `where`.
|
|
981
|
+
function gatedBy(raw, gates, lpath, where) {
|
|
982
|
+
if (raw === undefined) return [];
|
|
983
|
+
need(isObject(raw), `${lpath}.gates must map gate names to exponents, e.g. { m: 3, h: 1 }`);
|
|
984
|
+
return Object.entries(raw).map(([gname, p]) => {
|
|
985
|
+
const q = gates.findIndex((gt) => gt.name === gname);
|
|
986
|
+
need(q >= 0, `${lpath}.gates.${gname}: no such gate${gates.length ? ` (there are ${gates.map((gt) => gt.name).join(', ')})` : ` (define it in ${where}.gates)`}`);
|
|
987
|
+
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)}`);
|
|
988
|
+
return [q, p];
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
function normalizeGates(raw, where, matL, matR, across = 'the face') {
|
|
992
|
+
if (raw === undefined) return [];
|
|
993
|
+
need(isObject(raw), `${where}.gates must map gate names to { alpha, beta }`);
|
|
994
|
+
need(!matL.phiFree && !matR.phiFree, `${where}.gates: a gate follows the voltage across ${across}, which needs φ there`);
|
|
995
|
+
return Object.entries(raw).map(([name, gdef]) => {
|
|
996
|
+
const gpath = `${where}.gates.${name}`;
|
|
997
|
+
need(isObject(gdef), `${gpath} must be { alpha, beta }`);
|
|
998
|
+
fields(gdef, gpath, ['alpha', 'beta']);
|
|
999
|
+
const rate = (r, rpath) => {
|
|
1000
|
+
need(isObject(r) && GATE_RATE_TYPES.includes(r.type), `${rpath} must be { type, rate, midpoint, scale } with type one of ${GATE_RATE_TYPES.join(', ')}`);
|
|
1001
|
+
fields(r, rpath, ['type', 'rate', 'midpoint', 'scale']);
|
|
1002
|
+
const scale = finite(r.scale, `${rpath}.scale (V)`);
|
|
1003
|
+
need(scale !== 0, `${rpath}.scale (V) must not be zero`);
|
|
1004
|
+
return { type: r.type, rate: positive(r.rate, `${rpath}.rate (1/s)`), midpoint: finite(r.midpoint, `${rpath}.midpoint (V)`), scale };
|
|
1005
|
+
};
|
|
1006
|
+
return { name, alpha: rate(gdef.alpha, `${gpath}.alpha`), beta: rate(gdef.beta, `${gpath}.beta`) };
|
|
1007
|
+
});
|
|
944
1008
|
}
|
|
945
1009
|
|
|
946
1010
|
// A conductor (a metal, or a fast ion conductor): only its one mobile carrier, whose single
|
|
@@ -1036,7 +1100,7 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
|
|
|
1036
1100
|
}
|
|
1037
1101
|
const reactions = normalizeFaceReactions(idef, where, matL, matR, species, speciesIndex, RT);
|
|
1038
1102
|
checkReactingLinks(reactions, idef, where, matL, matR, species);
|
|
1039
|
-
return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, conductor: { side, i: metal.i } };
|
|
1103
|
+
return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
|
|
1040
1104
|
}
|
|
1041
1105
|
|
|
1042
1106
|
function defaultInterfaceLinks(matL, matR, species) {
|
|
@@ -1109,6 +1173,22 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1109
1173
|
links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
|
|
1110
1174
|
continue;
|
|
1111
1175
|
}
|
|
1176
|
+
if (link.type === 'velocity') {
|
|
1177
|
+
// A surface velocity: N_in = v (c_eq − c), c_eq the edge's concentration in equilibrium
|
|
1178
|
+
// with the outside level (thermionic emission over a barrier, a contact's surface
|
|
1179
|
+
// recombination velocity), v c (e^((μ̄_out − μ̄)/RT) − 1) for any statistics.
|
|
1180
|
+
const v = positive(link.v, `${lpath}.v (m/s)`);
|
|
1181
|
+
if (species[i].z === 0) {
|
|
1182
|
+
need(link.offset === undefined, `${lpath}: a neutral species' outside level is its mu (J/mol), not an offset`);
|
|
1183
|
+
links[i] = { type: 'velocity', v, mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
need(link.mu === undefined, `${lpath}: a charged species' outside level is an offset from the terminal voltage, not a mu`);
|
|
1187
|
+
const offset = link.offset ?? (terminal === i ? 0 : undefined);
|
|
1188
|
+
need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir; only the terminal species defaults to 0`);
|
|
1189
|
+
links[i] = { type: 'velocity', v, offset: finite(offset, `${lpath}.offset`) };
|
|
1190
|
+
continue;
|
|
1191
|
+
}
|
|
1112
1192
|
if (link.type === 'exchange') {
|
|
1113
1193
|
// A neutral species exchanging with the outside at its μ: N_in = k (μ_out − μ)/RT.
|
|
1114
1194
|
need(species[i].z === 0, `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
|
|
@@ -1140,12 +1220,19 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1140
1220
|
const bath = cdef.bath;
|
|
1141
1221
|
need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
|
|
1142
1222
|
fields(bath, `${path}.bath`, ['c', 'reference', 'offset']);
|
|
1143
|
-
|
|
1144
|
-
|
|
1223
|
+
// Without a reference, V is the bath's φ (an ideal salt bridge: the membrane potential's
|
|
1224
|
+
// convention), and the terminal is the first charged species in it.
|
|
1225
|
+
const byPhi = bath.reference === undefined;
|
|
1226
|
+
if (byPhi) need(bath.offset === undefined, `${path}.bath.offset: an offset places the reference species, and this bath has none (its V is its φ)`);
|
|
1227
|
+
else need(speciesIndex.has(bath.reference), `${path}.bath.reference: unknown species ${JSON.stringify(bath.reference)}${known(speciesIndex)}`);
|
|
1228
|
+
const r = byPhi
|
|
1229
|
+
? species.findIndex((sp) => sp.z !== 0 && bath.c[sp.name] > 0)
|
|
1230
|
+
: speciesIndex.get(bath.reference);
|
|
1231
|
+
need(r >= 0, `${path}.bath: the bath needs a charged species`);
|
|
1145
1232
|
need(species[r].z !== 0, `${path}.bath.reference: the reference species must be charged`);
|
|
1146
|
-
need(bath.c[
|
|
1147
|
-
need(cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
|
|
1148
|
-
terminal = r;
|
|
1233
|
+
need(bath.c[species[r].name] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
|
|
1234
|
+
need(byPhi || cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
|
|
1235
|
+
terminal = byPhi && terminal !== null ? terminal : r;
|
|
1149
1236
|
let charge = region.fixedCharge / FARADAY, scale = Math.abs(charge);
|
|
1150
1237
|
const cb = new Float64Array(species.length);
|
|
1151
1238
|
for (const [sname, v] of Object.entries(bath.c)) {
|
|
@@ -1161,7 +1248,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
|
|
|
1161
1248
|
const zb = bathZeta(mat, cb, region.background);
|
|
1162
1249
|
const level = (i) => mat.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
|
|
1163
1250
|
const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
|
|
1164
|
-
const beta = refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
|
|
1251
|
+
const beta = byPhi ? 0 : refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
|
|
1165
1252
|
for (let i = 0; i < species.length; i++) {
|
|
1166
1253
|
if (!(cb[i] > 0)) continue;
|
|
1167
1254
|
links[i] =
|
package/src/index.js
CHANGED
|
@@ -166,7 +166,12 @@ export class Device {
|
|
|
166
166
|
* @returns {Solution}
|
|
167
167
|
*/
|
|
168
168
|
solve(opts) {
|
|
169
|
-
|
|
169
|
+
// A solve that fails reports the state it failed in, and leaves the device as it was: a
|
|
170
|
+
// device with no steady state (a host filling at a current) can still advance() from there.
|
|
171
|
+
const snap = this.solver._snapshot();
|
|
172
|
+
const sol = makeSolution(this.solver, this.solver.solveSteady(opts));
|
|
173
|
+
if (!sol.converged) this.solver._restore(snap);
|
|
174
|
+
return sol;
|
|
170
175
|
}
|
|
171
176
|
|
|
172
177
|
/**
|
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
|
-
|
|
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),
|
|
@@ -180,7 +191,7 @@ export function makeSolution(solver, result = {}) {
|
|
|
180
191
|
|
|
181
192
|
// Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
|
|
182
193
|
// gate contacts), check the local Debye length against the adjacent cell.
|
|
183
|
-
sol.warnings = [...(model.warnings ?? [])];
|
|
194
|
+
sol.warnings = [...(model.warnings ?? []), ...(result.stopped ? [result.stopped] : [])];
|
|
184
195
|
// A surface covered almost completely: its coverages' η barely move anything below ~1e-10 bare,
|
|
185
196
|
// and Newton loses them (a transient can stall there).
|
|
186
197
|
sol.ports.forEach((p) => {
|
|
@@ -196,9 +207,17 @@ export function makeSolution(solver, result = {}) {
|
|
|
196
207
|
const cond = solver.conditioning, un = solver.unreached;
|
|
197
208
|
if (result.converged === false && un) {
|
|
198
209
|
const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
|
|
210
|
+
// The opposite sign within reach: likely a sign slip (a drive is the current into the device).
|
|
211
|
+
const flipped = Math.min(I0, I1) <= -un.target && -un.target <= Math.max(I0, I1);
|
|
199
212
|
sol.warnings.push(
|
|
200
213
|
`${un.terminal}: no steady state passes the driven current ${g(un.target)} ${A}; held from ${g(V0)} V to ${g(V1)} V ` +
|
|
201
|
-
`it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage
|
|
214
|
+
`it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage.` +
|
|
215
|
+
(flipped ? ` (It passes ${g(-un.target)} ${A}: if that was meant, a terminal's current is into the device.)` : '') +
|
|
216
|
+
// Nothing passed at any voltage: the device only stores what comes in (a host filling, a
|
|
217
|
+
// capacitor charging), so it has no steady state at a current.
|
|
218
|
+
(Math.max(Math.abs(I0), Math.abs(I1)) < 1e-6 * Math.abs(un.target)
|
|
219
|
+
? ' It passes almost nothing steadily at any voltage, so it may only store what comes in (a host filling, a capacitor charging), which has no steady state at a current: advance() in time instead.'
|
|
220
|
+
: ''),
|
|
202
221
|
);
|
|
203
222
|
} else if (result.converged === false && cond && cond.digits > 12) {
|
|
204
223
|
const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
|
|
@@ -208,6 +227,12 @@ export function makeSolution(solver, result = {}) {
|
|
|
208
227
|
'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).',
|
|
209
228
|
);
|
|
210
229
|
}
|
|
230
|
+
if (result.converged === false && !result.stopped && !un && !(cond && cond.digits > 12)) {
|
|
231
|
+
sol.warnings.push(
|
|
232
|
+
'the solve did not converge, and nothing specific showed why. ' +
|
|
233
|
+
'Check the definition with describe() (from driftlet/kit); approach this state in smaller steps (a sweep, or advance() in time); or refine the grid.',
|
|
234
|
+
);
|
|
235
|
+
}
|
|
211
236
|
const debye = (g) => {
|
|
212
237
|
const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
|
|
213
238
|
const s2 = solver.screening(g); // zᵀKz: Σ z² c for ideal statistics
|