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.
- package/README.md +50 -36
- package/docs/device.md +129 -7
- package/docs/kit.md +12 -0
- package/docs/numerics.md +188 -22
- package/docs/reliability.md +122 -0
- package/llms.txt +55 -8
- package/package.json +2 -1
- package/src/blockTridiagonal.js +17 -4
- package/src/channels.js +73 -0
- package/src/checks.js +17 -10
- package/src/describe.js +4 -2
- package/src/device.js +55 -11
- package/src/difference.js +142 -0
- package/src/index.js +22 -6
- package/src/kit.js +1 -0
- package/src/recorder.js +1 -1
- package/src/solution.js +20 -3
- package/src/solver.js +1166 -146
- package/types/blockTridiagonal.d.ts +9 -1
- package/types/channels.d.ts +39 -0
- package/types/difference.d.ts +30 -0
- package/types/index.d.ts +2 -1
- package/types/kit.d.ts +1 -0
- package/types/recorder.d.ts +7 -3
- package/types/solver.d.ts +77 -11
package/src/channels.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
116
|
-
for (const { i, nu } of rx.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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 &&
|
|
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 ` +
|