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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "driftlet",
3
- "version": "0.11.4",
3
+ "version": "0.13.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,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
- 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²${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
- links[i] = { ...link };
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
- need(speciesIndex.has(bath.reference), `${path}.bath.reference: unknown species ${JSON.stringify(bath.reference)}${known(speciesIndex)}`);
1144
- const r = speciesIndex.get(bath.reference);
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[bath.reference] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
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
- return makeSolution(this.solver, this.solver.solveSteady(opts));
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
- 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),
@@ -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