driftlet 0.10.0 → 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.
@@ -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/checks.js CHANGED
@@ -96,7 +96,9 @@ function balance(device, sol) {
96
96
  home[i][r] = ledgers.length - 1;
97
97
  });
98
98
  });
99
- const add = (i, r, what, rate) => {
99
+ const partners = new Map(); // a reaction term's species: its net rate is round-off against any of theirs
100
+ const add = (i, r, what, rate, species) => {
101
+ if (species) partners.set(what, species);
100
102
  const l = home[i][r];
101
103
  if (!(l >= 0 && rate !== 0 && Number.isFinite(rate))) return;
102
104
  const same = ledgers[l].terms.find((t) => t.what === what); // a bulk reaction, region by region
@@ -112,8 +114,9 @@ function balance(device, sol) {
112
114
  model.reactions.forEach((rx, k) => {
113
115
  const what = label(def.bulkReactions?.[k]);
114
116
  sol.bulkReactions[k].regions.forEach((total, r) => {
115
- for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total);
116
- for (const { i, nu } of rx.products) add(i, r, what, nu * total);
117
+ const all = [...rx.reactants, ...rx.products].map((p) => p.i);
118
+ for (const { i, nu } of rx.reactants) add(i, r, what, -nu * total, all);
119
+ for (const { i, nu } of rx.products) add(i, r, what, nu * total, all);
117
120
  });
118
121
  });
119
122
  interfaces.forEach((itf, f) => {
@@ -129,7 +132,8 @@ function balance(device, sol) {
129
132
  });
130
133
  itf.reactions.forEach((rx, k) => {
131
134
  const rate = Af * sol.interfaces[f].rates[k], what = `${label(def.interfaces?.[f]?.reactions?.[k])} at face ${f}`;
132
- for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate);
135
+ const all = rx.part.map((p) => p.i);
136
+ for (const { i, nu, side } of rx.part) add(i, f + side, what, nu * rate, all);
133
137
  });
134
138
  });
135
139
  // A species in one compartment is named alone; one in several, by where.
@@ -140,10 +144,12 @@ function balance(device, sol) {
140
144
  l.title = several[l.i] ? `${l.species} in ${l.compartment}` : l.species;
141
145
  }
142
146
 
143
- // A species at rest (nothing beyond round-off moving, against what it could carry) isn't reported.
147
+ // A species at rest (nothing beyond round-off moving, against what it or its reactions' partners
148
+ // could carry) isn't reported.
144
149
  const size = (l) => Math.max(0, ...l.terms.map((t) => Math.abs(t.rate)));
145
- const natural = naturalScales(device, sol);
146
- const moving = ledgers.filter((l) => size(l) > 1e-9 * natural[l.i]);
150
+ const natural = naturalScales(device, sol), inSolution = naturalScales(device, sol, false); // (partners' scale: not a metal's conductance)
151
+ const scale = (l) => Math.max(natural[l.i], ...l.terms.flatMap((t) => (partners.get(t.what) ?? []).map((j) => inSolution[j])));
152
+ const moving = ledgers.filter((l) => size(l) > 1e-8 * scale(l));
147
153
  let worst = 0;
148
154
  const lines = [];
149
155
  for (const l of moving) {
@@ -167,8 +173,9 @@ function balance(device, sol) {
167
173
 
168
174
  // What each species could carry, mol/(m²·s): by transport, D·c/L in each region (a metal's
169
175
  // carrier, σRT/(z²F²L)), and by its bulk reactions, their one-way rates. In equilibrium the net
170
- // flows are round-off of these.
171
- function naturalScales(device, sol) {
176
+ // flows are round-off of these. Without `conductors`, transport through conductor regions isn't
177
+ // counted.
178
+ function naturalScales(device, sol, conductors = true) {
172
179
  const { model, solver } = device, { species, grid } = model;
173
180
  // (totals through the cross-section, as the ledgers are: its largest in each region)
174
181
  const area = model.regions.map((_, r) => Math.max(...grid.area.subarray(grid.regionStart[r], grid.regionEnd[r] + 1)));
@@ -177,7 +184,7 @@ function naturalScales(device, sol) {
177
184
  model.regions.forEach((reg, r) => {
178
185
  const mat = model.materials[reg.material], L = reg.length;
179
186
  if (mat.conductor) {
180
- if (mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
187
+ if (conductors && mat.conductor.i === i) S = Math.max(S, (area[r] * mat.conductor.sigma * model.RT) / (sp.z * sp.z * FARADAY * FARADAY * L));
181
188
  return;
182
189
  }
183
190
  if (!mat.present[i] || !(mat.D[i] > 0)) return;
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) {
@@ -0,0 +1,142 @@
1
+ // The dilute kernels' Jacobian terms kept in difference form, for an exact J·v.
2
+ //
3
+ // Assembled, a Scharfetter–Gummel flux's dependence on η at its two ends is two entries, G and
4
+ // −G(1 + E), that cancel for a nearly uniform v; where G is huge (an inversion layer's 0.1 nm
5
+ // cells), J·v loses the flux to round-off. Kept as terms, each is evaluated on differences first:
6
+ // δN = a·(δη_L − z δφ̂_L) + b·(δη_R − δη_L) + d·(δφ̂_R − δφ̂_L),
7
+ // with a = −G E (through c_L), b = −G(1 + E) and d = z ∂N/∂Δ (through the drift). A node's
8
+ // storage and space charge depend on δη_i − z_i δφ̂ alone; the displacement on δφ̂_R − δφ̂_L. A
9
+ // uniform shift of a region (z_i s on each η_i, s on φ̂) then gives exactly zero, as it must.
10
+ //
11
+ // Indices are compact (the solver's rix), with the sink (index N) for an unknown that isn't one:
12
+ // vectors passed in are read there as zero.
13
+ export class DifferenceTerms {
14
+ constructor(N, nodes, segments) {
15
+ this.N = N;
16
+ this.dead = new Uint8Array(N + 1); // rows replaced since (a contact's level, a held stretch)
17
+ this.nI = new Int32Array(nodes); // node term: η column (= its balance row), φ̂ column (= Gauss row)
18
+ this.nP = new Int32Array(nodes);
19
+ this.nZ = new Float64Array(nodes);
20
+ this.nMass = new Float64Array(nodes); // storage per unit (δη − z δφ̂), times 1/dt (or iω)
21
+ this.nCharge = new Float64Array(nodes); // into the Gauss row, per unit (δη − z δφ̂)
22
+ this.sI = new Int32Array(4 * segments); // segment term: η_L, η_R, φ̂_L, φ̂_R
23
+ this.sZ = new Float64Array(segments);
24
+ this.sC = new Float64Array(3 * segments); // a, b, d
25
+ this.dI = new Int32Array(2 * segments); // displacement: φ̂_L, φ̂_R
26
+ this.dK = new Float64Array(segments);
27
+ this.combos = []; // [rk, ri, w]: row rk += w × row ri (strictly neutral nodes' charge rows)
28
+ this.t = new Float64Array(N + 1);
29
+ this.x = new Float64Array(N + 1);
30
+ this.reset();
31
+ }
32
+
33
+ // Afresh for an assembly; `rate` multiplies storage in captured rows (1/dt).
34
+ reset(rate = 0) {
35
+ this.rate = rate;
36
+ this.nn = this.ns = this.nd = 0;
37
+ this.dead.fill(0);
38
+ this.combos.length = 0;
39
+ }
40
+
41
+ node(i, p, z, mass, charge) {
42
+ const k = this.nn++;
43
+ this.nI[k] = i;
44
+ this.nP[k] = p;
45
+ this.nZ[k] = z;
46
+ this.nMass[k] = mass;
47
+ this.nCharge[k] = charge;
48
+ }
49
+
50
+ segment(iL, iR, pL, pR, z, a, b, d) {
51
+ const k = this.ns++;
52
+ this.sI[4 * k] = iL;
53
+ this.sI[4 * k + 1] = iR;
54
+ this.sI[4 * k + 2] = pL;
55
+ this.sI[4 * k + 3] = pR;
56
+ this.sZ[k] = z;
57
+ this.sC[3 * k] = a;
58
+ this.sC[3 * k + 1] = b;
59
+ this.sC[3 * k + 2] = d;
60
+ }
61
+
62
+ displacement(pL, pR, k) {
63
+ const q = this.nd++;
64
+ this.dI[2 * q] = pL;
65
+ this.dI[2 * q + 1] = pR;
66
+ this.dK[q] = k;
67
+ }
68
+
69
+ kill(row) {
70
+ this.dead[row] = 1;
71
+ }
72
+
73
+ combine(rk, ri, w) {
74
+ this.combos.push([rk, ri, w]);
75
+ }
76
+
77
+ // into[col] += w × row's coefficients, the steady ones plus `mass` times the storage's (a row
78
+ // captured as plain entries: a contact's current, read before its row is replaced).
79
+ capture(row, w, mass, into) {
80
+ const N = this.N, add = (col, v) => {
81
+ if (col < N && v !== 0) into[col] += w * v;
82
+ };
83
+ for (let k = 0; k < this.nn; k++) {
84
+ const i = this.nI[k], p = this.nP[k], z = this.nZ[k];
85
+ let f = 0;
86
+ if (row === i) f += mass * this.nMass[k];
87
+ if (row === p) f += this.nCharge[k];
88
+ if (f === 0) continue;
89
+ add(i, f);
90
+ add(p, -z * f);
91
+ }
92
+ for (let k = 0; k < this.ns; k++) {
93
+ const iL = this.sI[4 * k], iR = this.sI[4 * k + 1];
94
+ const s = row === iL ? 1 : row === iR ? -1 : 0;
95
+ if (s === 0) continue;
96
+ const z = this.sZ[k], a = this.sC[3 * k], b = this.sC[3 * k + 1], d = this.sC[3 * k + 2];
97
+ add(iL, s * (a - b));
98
+ add(iR, s * b);
99
+ add(this.sI[4 * k + 2], s * (-z * a - d));
100
+ add(this.sI[4 * k + 3], s * d);
101
+ }
102
+ for (let q = 0; q < this.nd; q++) {
103
+ const pL = this.dI[2 * q], pR = this.dI[2 * q + 1], s = row === pL ? 1 : row === pR ? -1 : 0;
104
+ if (s === 0) continue;
105
+ add(pL, s * this.dK[q]);
106
+ add(pR, -s * this.dK[q]);
107
+ }
108
+ }
109
+
110
+ // out += (steady terms) × v + mass × (storage terms) × v, over the live rows. v is read over
111
+ // its first N entries; transformed (the solver's charge rows), it's mapped back by the caller.
112
+ apply(v, out, steady = 1, mass = 0) {
113
+ const { N, t, x } = this;
114
+ for (let j = 0; j < N; j++) x[j] = v[j];
115
+ x[N] = 0;
116
+ t.fill(0);
117
+ const { nI, nP, nZ, nMass, nCharge } = this;
118
+ for (let k = 0; k < this.nn; k++) {
119
+ const i = nI[k], p = nP[k], w = x[i] - nZ[k] * x[p];
120
+ t[i] += mass * nMass[k] * w;
121
+ t[p] += steady * nCharge[k] * w;
122
+ }
123
+ if (steady !== 0) {
124
+ const { sI, sZ, sC, dI, dK } = this;
125
+ for (let k = 0; k < this.ns; k++) {
126
+ const iL = sI[4 * k], iR = sI[4 * k + 1], pL = sI[4 * k + 2], pR = sI[4 * k + 3];
127
+ const dN = steady * (sC[3 * k] * (x[iL] - sZ[k] * x[pL]) + sC[3 * k + 1] * (x[iR] - x[iL]) + sC[3 * k + 2] * (x[pR] - x[pL]));
128
+ t[iL] += dN;
129
+ t[iR] -= dN;
130
+ }
131
+ for (let q = 0; q < this.nd; q++) {
132
+ const pL = dI[2 * q], pR = dI[2 * q + 1], dD = -steady * dK[q] * (x[pR] - x[pL]);
133
+ t[pL] += dD;
134
+ t[pR] -= dD;
135
+ }
136
+ }
137
+ for (let j = 0; j < N; j++) if (this.dead[j]) t[j] = 0;
138
+ for (const [rk, ri, w] of this.combos) t[rk] += w * t[ri];
139
+ for (let j = 0; j < N; j++) out[j] += t[j];
140
+ return out;
141
+ }
142
+ }
package/src/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // driftlet: 1D drift–diffusion–reaction solver.
2
2
 
3
- import { normalizeDevice } from './device.js';
3
+ import { normalizeDevice, DeviceError } from './device.js';
4
4
  import { Solver, SolverError } from './solver.js';
5
5
  import { makeSolution } from './solution.js';
6
6
  import { merge } from './merge.js';
@@ -64,20 +64,32 @@ export class Device {
64
64
 
65
65
  /**
66
66
  * Change part of the definition (deep-merged). A contact given V or I drops the other, so
67
- * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. A change to the terminals'
67
+ * `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
68
+ * `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
68
69
  * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
69
70
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
70
71
  * @param {object} patch a partial device definition, merged into the current one
71
72
  * @returns {this}
72
73
  */
73
74
  set(patch) {
74
- // A contact given a new kind of drive drops the old one: { I: 0 } replaces a held V.
75
+ // A terminal given a new kind of drive drops the old one: { I: 0 } replaces a held V.
76
+ const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
75
77
  for (const side of ['left', 'right']) {
76
78
  const c = patch?.contacts?.[side];
77
- if (c && typeof c === 'object') {
78
- if ('I' in c && !('V' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, V: undefined } } };
79
- else if ('V' in c && !('I' in c)) patch = { ...patch, contacts: { ...patch.contacts, [side]: { ...c, I: undefined } } };
79
+ if (c && typeof c === 'object') patch = { ...patch, contacts: { ...patch.contacts, [side]: redrive(c) } };
80
+ }
81
+ // Ports: an array replaces them all; an object patches them by name, { gate: { V: 0.2 } }.
82
+ const ports = patch?.ports;
83
+ if (ports !== undefined && !Array.isArray(ports)) {
84
+ if (ports === null || typeof ports !== 'object') throw new DeviceError('set: ports must be an array (replacing them all) or an object of patches by port name, e.g. { gate: { V: 0.2 } }');
85
+ const old = this.def.ports ?? [];
86
+ for (const name of Object.keys(ports)) {
87
+ if (!old.some((p, k) => (p.name ?? `ports[${k}]`) === name)) throw new DeviceError(`set: ports.${name}: no port named '${name}'${old.length ? ` (the ports: ${old.map((p, k) => p.name ?? `ports[${k}]`).join(', ')})` : ''}`);
80
88
  }
89
+ patch = { ...patch, ports: old.map((p, k) => {
90
+ const q = ports[p.name ?? `ports[${k}]`];
91
+ return q === undefined ? p : merge(p, redrive(q));
92
+ }) };
81
93
  }
82
94
  const def = merge(this.def, patch);
83
95
  // Only the terminals' drives changed: update them in place, keeping the solver and its state
@@ -97,6 +109,10 @@ export class Device {
97
109
  if (old && old.u.length === solver.u.length && old.n === solver.n) {
98
110
  solver.u.set(old.u);
99
111
  solver.uLo.set(old.uLo);
112
+ // A node newly on an electrode's surface (a window moved, a surface added) starts at its
113
+ // starting coverages, not at whatever its slots held.
114
+ const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
115
+ for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
100
116
  solver.computeConcentrations();
101
117
  solver.time = old.time;
102
118
  solver.contactDEnd = old.contactDEnd;
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),
@@ -193,8 +204,14 @@ export function makeSolution(solver, result = {}) {
193
204
  );
194
205
  }
195
206
  });
196
- const cond = solver.conditioning;
197
- if (result.converged === false && cond && cond.digits > 12) {
207
+ const cond = solver.conditioning, un = solver.unreached;
208
+ if (result.converged === false && un) {
209
+ const [[V0, I0], [V1, I1]] = [un.low, un.high], g = (x) => x.toPrecision(3), A = model.geometry.type === 'planar' ? 'A/m²' : 'A';
210
+ sol.warnings.push(
211
+ `${un.terminal}: no steady state passes the driven current ${g(un.target)} ${A}; held from ${g(V0)} V to ${g(V1)} V ` +
212
+ `it passed ${g(I0)} to ${g(I1)} ${A} (a limiting current, or kinetics too slow). Drive less, or hold a voltage.`,
213
+ );
214
+ } else if (result.converged === false && cond && cond.digits > 12) {
198
215
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
199
216
  sol.warnings.push(
200
217
  `the steady system ${digits} near x = ${cond.x.toExponential(3)} m (${cond.where}): part of the device is held ` +