driftlet 0.12.0 → 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.12.0",
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": {
package/src/channels.js CHANGED
@@ -28,7 +28,7 @@ export function ghkCurrent(z, inside, outside, V, T) {
28
28
  }
29
29
 
30
30
  /**
31
- * Hodgkin and Huxley's squid-axon channels, for a membrane face with the outside on its left
31
+ * Hodgkin and Huxley's squid-axon channels: for a membrane face with the outside on its left
32
32
  * (so the gates' voltage, φ_right − φ_left, is the membrane potential): the face's `gates`, and
33
33
  * `species` links for Na⁺ (P·m³h), K⁺ (P·n⁴) and the leak (ungated), to spread into the face
34
34
  * beside its φ law, `{ phi: { type: 'capacitive', C: 0.01 }, ...hh }`, adding links (or blocks)
@@ -40,13 +40,21 @@ export function ghkCurrent(z, inside, outside, V, T) {
40
40
  * (`leak`, of charge `leakZ`: Cl⁻ by default, whose Nernst level in squid is near their leak
41
41
  * reversal, −54 mV). The
42
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).
43
52
  * @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
53
+ * leak?: string, leakZ?: number, at?: number, names?: { Na?: string, K?: string }, linear?: boolean, area?: number }} opts concentrations in
45
54
  * mol/m³ by species name, K, S/m², V
46
55
  * @returns {{ gates: object, species: object }}
47
56
  */
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');
57
+ export function hodgkinHuxley({ inside, outside, T = HH_T, g = {}, leak = 'Cl-', leakZ = -1, at = -65 * mV, names = {}, linear = false, area } = {}) {
50
58
  need(num(T) && T > 0, `hodgkinHuxley: T must be a temperature in K, got ${T}`);
51
59
  need(num(at), `hodgkinHuxley: at must be a voltage (V), got ${at}`);
52
60
  const G = { Na: 1200, K: 360, leak: 3, ...g };
@@ -54,6 +62,21 @@ export function hodgkinHuxley({ inside, outside, T = HH_T, g = {}, leak = 'Cl-',
54
62
  const q10 = Math.exp(Math.log(3) * ((T - HH_T) / 10)); // Q₁₀ = 3
55
63
  const scaled = (r) => ({ ...r, rate: r.rate * q10 });
56
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)');
57
80
  const P = (name, z, gval, what) => {
58
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}`);
59
82
  need(num(gval) && gval > 0, `hodgkinHuxley: g.${what} must be a conductance > 0 (S/m²), got ${gval}`);
package/src/describe.js CHANGED
@@ -202,7 +202,7 @@ export function describe(def) {
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
+ 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}` : ''}`] : [];
206
206
  });
207
207
  const gates = (itf.gates ?? []).map((g) => g.name);
208
208
  lines.push(
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);
@@ -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', 'gates']);
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);
@@ -691,9 +692,27 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
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);
693
694
  // Gates (a membrane's channels): they follow the voltage across the port's capacitance.
694
- const gates = normalizeGates(pdef.gates, path, mat, mat);
695
+ const gates = normalizeGates(pdef.gates, path, mat, mat, "the port's capacitance");
695
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`);
696
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
+ }
697
716
  const links = species.map(() => ({ type: 'blocked' }));
698
717
  for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
699
718
  const lpath = `${path}.species.${sname}`;
@@ -708,14 +727,15 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
708
727
  const z = species[i].z;
709
728
  // The outside level: an offset from V for charged species, an absolute μ for neutral ones.
710
729
  let level;
730
+ const fromBath = bathLevel?.(i);
711
731
  if (z === 0) {
712
732
  need(link.type !== 'conductance', `${lpath}: a neutral species exchanges by { type: 'exchange', k, mu }`);
713
- 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` : ''})`) };
714
734
  } else {
715
735
  need(link.type !== 'exchange', `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
716
736
  need(link.mu === undefined, `${lpath}: a charged species is held by an offset from the port voltage, not by mu`);
717
- const offset = link.offset ?? (terminal === i ? 0 : undefined);
718
- 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)`);
719
739
  level = { offset: finite(offset, `${lpath}.offset`) };
720
740
  }
721
741
  // On a metal, G is a lumped conductance per area (S/m²), spread over its thickness.
@@ -968,10 +988,10 @@ function gatedBy(raw, gates, lpath, where) {
968
988
  return [q, p];
969
989
  });
970
990
  }
971
- function normalizeGates(raw, where, matL, matR) {
991
+ function normalizeGates(raw, where, matL, matR, across = 'the face') {
972
992
  if (raw === undefined) return [];
973
993
  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`);
994
+ need(!matL.phiFree && !matR.phiFree, `${where}.gates: a gate follows the voltage across ${across}, which needs φ there`);
975
995
  return Object.entries(raw).map(([name, gdef]) => {
976
996
  const gpath = `${where}.gates.${name}`;
977
997
  need(isObject(gdef), `${gpath} must be { alpha, beta }`);
@@ -1153,6 +1173,22 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1153
1173
  links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
1154
1174
  continue;
1155
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
+ }
1156
1192
  if (link.type === 'exchange') {
1157
1193
  // A neutral species exchanging with the outside at its μ: N_in = k (μ_out − μ)/RT.
1158
1194
  need(species[i].z === 0, `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
@@ -1184,12 +1220,19 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1184
1220
  const bath = cdef.bath;
1185
1221
  need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
1186
1222
  fields(bath, `${path}.bath`, ['c', 'reference', 'offset']);
1187
- need(speciesIndex.has(bath.reference), `${path}.bath.reference: unknown species ${JSON.stringify(bath.reference)}${known(speciesIndex)}`);
1188
- 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`);
1189
1232
  need(species[r].z !== 0, `${path}.bath.reference: the reference species must be charged`);
1190
- need(bath.c[bath.reference] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
1191
- need(cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
1192
- 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;
1193
1236
  let charge = region.fixedCharge / FARADAY, scale = Math.abs(charge);
1194
1237
  const cb = new Float64Array(species.length);
1195
1238
  for (const [sname, v] of Object.entries(bath.c)) {
@@ -1205,7 +1248,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1205
1248
  const zb = bathZeta(mat, cb, region.background);
1206
1249
  const level = (i) => mat.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
1207
1250
  const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
1208
- 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
1209
1252
  for (let i = 0; i < species.length; i++) {
1210
1253
  if (!(cb[i] > 0)) continue;
1211
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/solution.js CHANGED
@@ -191,7 +191,7 @@ export function makeSolution(solver, result = {}) {
191
191
 
192
192
  // Resolution warnings: where the model resolves a double layer (dipole or capacitive faces,
193
193
  // gate contacts), check the local Debye length against the adjacent cell.
194
- sol.warnings = [...(model.warnings ?? [])];
194
+ sol.warnings = [...(model.warnings ?? []), ...(result.stopped ? [result.stopped] : [])];
195
195
  // A surface covered almost completely: its coverages' η barely move anything below ~1e-10 bare,
196
196
  // and Newton loses them (a transient can stall there).
197
197
  sol.ports.forEach((p) => {
@@ -207,9 +207,17 @@ export function makeSolution(solver, result = {}) {
207
207
  const cond = solver.conditioning, un = solver.unreached;
208
208
  if (result.converged === false && un) {
209
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);
210
212
  sol.warnings.push(
211
213
  `${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.`,
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
+ : ''),
213
221
  );
214
222
  } else if (result.converged === false && cond && cond.digits > 12) {
215
223
  const digits = Number.isFinite(cond.digits) ? `lost ${cond.digits.toFixed(0)} of its ~16 digits` : 'was exactly singular';
@@ -219,6 +227,12 @@ export function makeSolution(solver, result = {}) {
219
227
  'chain whose level nothing pins. Strengthen that coupling, or anchor the region (a port, a contact).',
220
228
  );
221
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
+ }
222
236
  const debye = (g) => {
223
237
  const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
224
238
  const s2 = solver.screening(g); // zᵀKz: Σ z² c for ideal statistics
package/src/solver.js CHANGED
@@ -261,12 +261,18 @@ export class SolverError extends Error {
261
261
  }
262
262
  }
263
263
 
264
+ // Lagrange weights at x through the points s0, s1, s2.
265
+ function lagrange3(x, s0, s1, s2) {
266
+ return [((x - s1) * (x - s2)) / ((s0 - s1) * (s0 - s2)), ((x - s0) * (x - s2)) / ((s1 - s0) * (s1 - s2)), ((x - s0) * (x - s1)) / ((s2 - s0) * (s2 - s1))];
267
+ }
268
+
264
269
  // A gate's rate law (see normalizeGates in device.js) at voltage V: [rate, ∂rate/∂V], 1/s and 1/(s·V).
265
270
  function gateRate(law, V) {
266
271
  const { rate, midpoint, scale } = law;
267
272
  if (law.type === 'exp') {
268
- const v = rate * Math.exp((V - midpoint) / scale);
269
- return [v, v / scale];
273
+ // (held finite where Newton's iterates stray to absurd voltages: β_m at −13 V would overflow)
274
+ const y = (V - midpoint) / scale, v = rate * Math.exp(Math.min(y, 700));
275
+ return [v, y > 700 ? 0 : v / scale];
270
276
  }
271
277
  if (law.type === 'sigmoid') {
272
278
  const e = Math.exp((midpoint - V) / scale), d = 1 + e;
@@ -1214,7 +1220,7 @@ export class Solver {
1214
1220
  /** η_i/RT that a fixed contact link imposes (or NaN if the link isn't fixed). */
1215
1221
  contactEta(side, i) {
1216
1222
  const link = this.model.contacts[side].species[i];
1217
- if (link.type !== 'equilibrium') return NaN;
1223
+ if (link.type !== 'equilibrium' && link.type !== 'velocity') return NaN;
1218
1224
  const z = this.z[i];
1219
1225
  return z === 0 ? link.mu / this.model.RT : (z * (this.termV[side === 'left' ? 0 : 1] + link.offset)) / this.VT;
1220
1226
  }
@@ -2519,6 +2525,7 @@ export class Solver {
2519
2525
  flux[i] += res[R[o]];
2520
2526
  this._captureRow(b, r, zF, C);
2521
2527
  this._replaceRow(b, r);
2528
+ for (const Bk of this.termB) Bk[R[o]] = 0; // (other ports' terms in the row replaced)
2522
2529
  this._j(b, r, b, r, 1);
2523
2530
  res[R[o]] = -deta;
2524
2531
  B[R[o]] += -z[i] / VT;
@@ -2746,7 +2753,7 @@ export class Solver {
2746
2753
  }
2747
2754
 
2748
2755
  // Conductance links: J (toward the device) = G (V_out − V_i), V_out = V_t + offset; exchange
2749
- // links (neutral species): N_in = k (μ_out − μ)/RT.
2756
+ // links (neutral species): N_in = k (μ_out − μ)/RT; velocity links, v (c_eq − c).
2750
2757
  for (let i = 0; i < n; i++) {
2751
2758
  const link = ct.species[i];
2752
2759
  const o = b * M + 1 + i;
@@ -2758,6 +2765,16 @@ export class Solver {
2758
2765
  res[R[o]] -= (Ac * link.G * (Vt + link.offset - Vi)) / (z[i] * F);
2759
2766
  this._j(b, 1 + i, b, 1 + i, (Ac * link.G * VT) / (z[i] * z[i] * F));
2760
2767
  B[R[o]] += (-Ac * link.G) / (z[i] * F);
2768
+ } else if (link.type === 'velocity') {
2769
+ // N_in = v c (e^(η_out − η) − 1): v (c_eq − c), c_eq in equilibrium with the outside.
2770
+ const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - (u[o] + uLo[o]), 700));
2771
+ const N = Ac * link.v * ci * (E - 1), d = this.dA;
2772
+ res[R[o]] -= N;
2773
+ d.fill(0);
2774
+ this._dlnc(g, i, 1, d);
2775
+ for (let s = 0; s <= n; s++) if (d[s] !== 0) this._j(b, 1 + i, b, s, -N * d[s]);
2776
+ this._j(b, 1 + i, b, 1 + i, Ac * link.v * ci * E);
2777
+ if (z[i] !== 0) B[R[o]] += (-Ac * link.v * ci * E * z[i]) / VT;
2761
2778
  }
2762
2779
  }
2763
2780
 
@@ -3408,7 +3425,7 @@ export class Solver {
3408
3425
  }
3409
3426
  } else {
3410
3427
  // (Order 2 once three consistent states can check it.)
3411
- const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, this.time + h) });
3428
+ const r = this.step(h, { method: this.history.length >= 2 ? method : 'be', guess: this._extrapolate(guess, h) });
3412
3429
  iterations += r.iterations;
3413
3430
  if (!r.converged) {
3414
3431
  rejected++;
@@ -3454,24 +3471,28 @@ export class Solver {
3454
3471
  }
3455
3472
  this.dtNext = dt;
3456
3473
  this.landing = undefined;
3457
- return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace };
3474
+ // Stopped short, other than on the caller's wall-time budget: say where, and why.
3475
+ let stopped;
3476
+ const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3477
+ if (failed) stopped = `${at}: a step didn't converge even when shortened to ${dt.toExponential(1)} s. A drive's jump too big for one step (ramp it), a driven current the device can't keep passing (a diode's reverse recovery past its storage time: stop there), or a device the steady solves also find hard.`;
3478
+ else if (this.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
3479
+ return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3458
3480
  }
3459
3481
 
3460
- // Quadratic (or linear) extrapolation of the state to time t, from the current state and the
3461
- // two most recent history entries: a starting guess for Newton.
3462
- _extrapolate(out, t) {
3482
+ // Quadratic (or linear) extrapolation of the state a time h ahead, from the current state and
3483
+ // the two most recent history entries: a starting guess for Newton. (In offsets built from the
3484
+ // steps' own lengths, never differences of absolute times: a second into a run, after a jump
3485
+ // that a fine grid resolves in steps of 1e-13 s, those differences keep only 3 digits.)
3486
+ _extrapolate(out, h) {
3463
3487
  const [e0, e1] = this.history;
3464
- const t0 = this.time, u = this.u;
3488
+ const u = this.u;
3465
3489
  if (!e0) return undefined;
3466
3490
  if (!e1) {
3467
- const l = (t - t0) / (t0 - e0.t);
3491
+ const l = h / e0.dt;
3468
3492
  for (let k = 0; k < out.length; k++) out[k] = u[k] + l * (u[k] - e0.u[k]);
3469
3493
  return out;
3470
3494
  }
3471
- const t1 = e0.t, t2 = e1.t;
3472
- const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
3473
- const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
3474
- const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
3495
+ const [l0, l1, l2] = lagrange3(h, 0, -e0.dt, -(e0.dt + e1.dt));
3475
3496
  for (let k = 0; k < out.length; k++) out[k] = l0 * u[k] + l1 * e0.u[k] + l2 * e1.u[k];
3476
3497
  return out;
3477
3498
  }
@@ -3480,7 +3501,7 @@ export class Solver {
3480
3501
  // linear after backward Euler), and the factor turning |u − pred| into the local error.
3481
3502
  _predict(pred, bdf) {
3482
3503
  const [e0, e1, e2] = this.history;
3483
- const t = this.time, h = e0.dt;
3504
+ const h = e0.dt;
3484
3505
  if (!e1) {
3485
3506
  pred.set(e0.u);
3486
3507
  return { scale: 1, order: 1 };
@@ -3488,18 +3509,15 @@ export class Solver {
3488
3509
  const hp = e1.dt;
3489
3510
  if (bdf && e2) {
3490
3511
  const hpp = e2.dt;
3491
- // Lagrange through (t0, u0), (t1, u1), (t2, u2) at t.
3492
- const t0 = e0.t, t1 = e1.t, t2 = e2.t;
3493
- const l0 = ((t - t1) * (t - t2)) / ((t0 - t1) * (t0 - t2));
3494
- const l1 = ((t - t0) * (t - t2)) / ((t1 - t0) * (t1 - t2));
3495
- const l2 = ((t - t0) * (t - t1)) / ((t2 - t0) * (t2 - t1));
3512
+ // Lagrange through the three states before at the new time (offsets as in _extrapolate).
3513
+ const [l0, l1, l2] = lagrange3(0, -h, -(h + hp), -(h + hp + hpp));
3496
3514
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k] + l2 * e2.u[k];
3497
3515
  const w = h / hp;
3498
3516
  const Cc = (h * h * h * (1 + w) * (1 + w)) / (w * (1 + 2 * w));
3499
3517
  const Cp = h * (h + hp) * (h + hp + hpp);
3500
3518
  return { scale: Cc / (Cc + Cp), order: 2 };
3501
3519
  }
3502
- const l0 = (t - e1.t) / (e0.t - e1.t), l1 = 1 - l0;
3520
+ const l0 = (h + hp) / hp, l1 = 1 - l0;
3503
3521
  for (let k = 0; k < pred.length; k++) pred[k] = l0 * e0.u[k] + l1 * e1.u[k];
3504
3522
  // Backward Euler's error against a linear predictor; after a BDF2 step without enough
3505
3523
  // history this overestimates, which is safe.
@@ -3720,7 +3738,15 @@ export class Solver {
3720
3738
  for (const X of ['A', 'B', 'C']) S[X] = sys[X].map((v, k) => (v - J[X][k]) * dts);
3721
3739
  const Cs = this.termC.map((v, k) => v.map((x, j) => (x - Ct[k][j]) * dts));
3722
3740
  const DIs = this.termDI.map((x, k) => (x - DI[k]) * dts);
3723
- const cuts = this._currentCuts();
3741
+ // Where the measured contact's current may be read across a segment: anywhere without
3742
+ // ports; with them, only between it and the nearest window (a port's exchange takes current
3743
+ // in or out along it). Never for a port's own terminal.
3744
+ let cuts = kT < 2 ? this._currentCuts() : null;
3745
+ if (cuts && this.model.ports.length > 0) {
3746
+ const windows = this.model.ports.flatMap((p) => Array.from(p.nodes));
3747
+ const lo = Math.min(...windows), hi = Math.max(...windows);
3748
+ cuts = cuts.filter((cut) => (kT === 0 ? cut.s + 1 <= lo : cut.s >= hi));
3749
+ }
3724
3750
  // J·v for GMRES, exact: the assembled J holds a flux's dependence on η_L and η_R as two
3725
3751
  // entries, and where both are huge (an inversion layer) and v nearly uniform, J·v loses the
3726
3752
  // flux to round-off. So the dilute kernels' terms are kept in difference form (see
@@ -3889,7 +3915,7 @@ export class Solver {
3889
3915
  Ii += im;
3890
3916
  for (let j = 0; j < Nc; j++) err += (Math.abs(c[j]) + w * Math.abs(cs[j])) * (eps * Math.hypot(p.r[j], p.i[j]) + p.floor);
3891
3917
  }
3892
- if (!cuts || kT >= 2) return { Ir, Ii, err };
3918
+ if (!cuts) return { Ir, Ii, err };
3893
3919
  const sgn = kT === 0 ? 1 : -1;
3894
3920
  for (const cut of cuts) {
3895
3921
  let re = 0, im = 0, e = 0;
@@ -3929,7 +3955,8 @@ export class Solver {
3929
3955
  // taken at its edges; and what's left, small, solved for to GMRES's floor, its error now
3930
3956
  // relative to itself. An electrolyte at its open circuit needs it: the response to its
3931
3957
  // terminal is nearly such a shift, and its current the slope of levels uniform to 1e-14,
3932
- // more than a double can hold. (Not with ports, whose exchange sees the shift.)
3958
+ // more than a double can hold. (A port's exchange sees the shift: its window's rows are
3959
+ // kept with the edges'.) So does a MOS capacitor's back contact, its channel held by a port.
3933
3960
  if (K === 0 && cuts && err > 1e-2 * Math.hypot(Ir, Ii)) {
3934
3961
  const [er, ei] = this._regionShift(yr, yi);
3935
3962
  const tr = new Float64Array(Nc), ti = new Float64Array(Nc);
@@ -4019,8 +4046,9 @@ export class Solver {
4019
4046
  return [er, ei];
4020
4047
  }
4021
4048
 
4022
- // Compact rows a region's uniform shift can touch: faces, the nodes beside them, and the two
4023
- // ends (whatever holds a level from outside). Inside a region, every row is unchanged by it.
4049
+ // Compact rows a region's uniform shift can touch: faces, the nodes beside them, the two ends
4050
+ // and the ports' windows (whatever holds a level from outside). Elsewhere inside a region,
4051
+ // every row is unchanged by it.
4024
4052
  _regionEdgeRows() {
4025
4053
  const { M, loc, nB } = this, R = this.rix;
4026
4054
  const edge = new Uint8Array(this.sys.size);
@@ -4036,16 +4064,17 @@ export class Solver {
4036
4064
  mark(b);
4037
4065
  mark(b + 1);
4038
4066
  }
4067
+ for (const port of this.model.ports) for (const g of port.nodes) mark(this.blockOfNode[g]);
4039
4068
  return edge;
4040
4069
  }
4041
4070
 
4042
- // The cuts where the impedance may read a small-signal current (null where the current
4043
- // differs between cuts: a device with ports). Each is the linearised total current toward +x
4044
- // through one segment of an ideal region, Σ (g + iω d)·δu over the unknowns idx: the
4045
- // Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the displacement's.
4071
+ // The cuts where the impedance may read a small-signal current. Each is the linearised total
4072
+ // current toward +x through one segment s of an ideal region, Σ (g + iω d)·δu over the
4073
+ // unknowns idx: the Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the
4074
+ // displacement's. Without ports it's the same through every cut; with them, only between a
4075
+ // contact and the nearest port's window (see _impedance).
4046
4076
  _currentCuts() {
4047
4077
  const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
4048
- if (model.ports.length > 0) return null;
4049
4078
  const { grid, materials, regions } = model;
4050
4079
  const cuts = [];
4051
4080
  for (let r = 0; r < regions.length; r++) {
@@ -4085,7 +4114,7 @@ export class Solver {
4085
4114
  }
4086
4115
  if (terms.size === 0) continue;
4087
4116
  const idx = [...terms.keys()];
4088
- cuts.push({ idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
4117
+ cuts.push({ s, idx, g: idx.map((x) => terms.get(x)[0]), d: idx.map((x) => terms.get(x)[1]) });
4089
4118
  }
4090
4119
  }
4091
4120
  return cuts;
@@ -4264,7 +4293,7 @@ export class Solver {
4264
4293
  // pseudo-transient ramp: one direct attempt first.
4265
4294
  const quick = (canContinue || this.hasGeneration) && direct && opts.continuation !== false;
4266
4295
  let r = this._solveSteady(quick ? { ...opts, maxSteps: 1 } : opts);
4267
- if (r.converged) this.solvedV = target;
4296
+ if (r.converged) this.solvedV = [level, target];
4268
4297
  if (r.converged) return r;
4269
4298
  // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
4270
4299
  // nothing, each solve warm from the last.
@@ -4278,7 +4307,7 @@ export class Solver {
4278
4307
  const g = this._generationContinuation(opts, r);
4279
4308
  dimmer = this.dimmer;
4280
4309
  if (g.converged) {
4281
- this.solvedV = target;
4310
+ this.solvedV = [level, target];
4282
4311
  return g;
4283
4312
  }
4284
4313
  r = g;
@@ -4319,17 +4348,23 @@ export class Solver {
4319
4348
  this.computeConcentrations();
4320
4349
  };
4321
4350
  restart();
4322
- // Ramp from the last converged voltage when the state is that solution (a warm start),
4323
- // otherwise from level terminals.
4324
- const from = this.solvedV !== undefined ? this.solvedV : level;
4325
- const c = this._continuation(opts, from, target, r, from !== level);
4351
+ // Ramp from the contacts' voltages at the last converged solve when the state is that
4352
+ // solution (a warm start): the left one first where it moved, the right held where it was,
4353
+ // then the right. Otherwise from level terminals.
4354
+ const solved = this.solvedV;
4355
+ let c;
4356
+ if (solved) {
4357
+ c = solved[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, solved[0], level, r, true, solved[1]);
4358
+ // (Ramping the right from level terminals, solve there first: a sweep's start.)
4359
+ if (c.converged) c = this._continuation(opts, 1, solved[1], target, c, c.ramped || solved[1] !== level);
4360
+ } else c = this._continuation(opts, 1, level, target, r, false);
4326
4361
  if (c.converged) {
4327
- this.solvedV = target;
4362
+ this.solvedV = [level, target];
4328
4363
  return c;
4329
4364
  }
4330
4365
  restart();
4331
4366
  r = this._solveSteady(opts); // the full pseudo-transient ramp
4332
- if (r.converged) this.solvedV = target;
4367
+ if (r.converged) this.solvedV = [level, target];
4333
4368
  return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
4334
4369
  }
4335
4370
 
@@ -4410,13 +4445,15 @@ export class Solver {
4410
4445
  // Floated, the system can lose what held it doesn't (a closed redox cell driven near its
4411
4446
  // limit: its bordered row 33 digits short), but a current-driven steady state is the held
4412
4447
  // one at the voltage that passes the target: found by held solves, by regula falsi
4413
- // (Illinois) within the bracket, to 1e-10 of the target.
4448
+ // (Illinois) within the bracket, to 1e-10 of the target, or at open circuit of the largest
4449
+ // current the march saw (a target of 0 is never met exactly).
4450
+ const scale = Math.max(Math.abs(target), ...seen.map(([, I]) => Math.abs(I)));
4414
4451
  try {
4415
4452
  let side = 0;
4416
4453
  for (let it = 0; it < 60; it++) {
4417
4454
  const Vm = Va - (fa * (Vb - Va)) / (fb - fa), fm = hold(Vm);
4418
4455
  if (!Number.isFinite(fm)) return fail();
4419
- if (Math.abs(fm) <= 1e-10 * Math.abs(target)) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4456
+ if (Math.abs(fm) <= 1e-10 * scale) return { converged: true, steps, iterations, history, residual: Math.abs(fm) };
4420
4457
  if (Math.sign(fm) === Math.sign(fb)) {
4421
4458
  [Vb, fb] = [Vm, fm];
4422
4459
  if (side === -1) fa /= 2;
@@ -4474,12 +4511,18 @@ export class Solver {
4474
4511
  }
4475
4512
  }
4476
4513
 
4477
- _continuation(opts, level, target, r, warm) {
4514
+ // Terminal k (a contact) ramped from `level` to `target` through steady solves, each warm from
4515
+ // the last (`hold`: the other contact's voltage meanwhile, if not its own). A warm ramp that has
4516
+ // nowhere to go converges only if what it started from did (`ramped`): the state it carries
4517
+ // must be some ramp's solution, never just the one a failed solve left.
4518
+ _continuation(opts, k, level, target, r, warm, hold) {
4478
4519
  const sub = this._directSteady() ? { ...opts, maxSteps: 1 } : opts;
4479
4520
  let V = level, dV = (target - level) / 8, steps = r.steps, iterations = r.iterations;
4480
4521
  const history = r.history.slice();
4522
+ if (warm && V === target) return { ...r, converged: r.ramped === true, steps, iterations, history, ramped: r.ramped };
4481
4523
  try {
4482
- this.sourceOverride.set(1, V);
4524
+ this.sourceOverride.set(k, V);
4525
+ if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4483
4526
  let q;
4484
4527
  if (!warm) {
4485
4528
  q = this._solveSteady(opts);
@@ -4501,7 +4544,7 @@ export class Solver {
4501
4544
  while (V !== target) {
4502
4545
  const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4503
4546
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4504
- this.sourceOverride.set(1, next);
4547
+ this.sourceOverride.set(k, next);
4505
4548
  q = this._solveSteady(sub);
4506
4549
  steps += q.steps;
4507
4550
  iterations += q.iterations;
@@ -4518,9 +4561,10 @@ export class Solver {
4518
4561
  if (Math.abs(dV) < 1e-6 * Math.abs(target - level)) return { converged: false, steps, iterations, history };
4519
4562
  }
4520
4563
  }
4521
- return { converged: true, steps, iterations, history };
4564
+ return { converged: true, steps, iterations, history, ramped: true };
4522
4565
  } finally {
4523
- this.sourceOverride.delete(1);
4566
+ this.sourceOverride.delete(k);
4567
+ if (hold !== undefined) this.sourceOverride.delete(1 - k);
4524
4568
  this._refreshSources();
4525
4569
  }
4526
4570
  }
package/src/stack.js CHANGED
@@ -87,16 +87,18 @@ export function ohmic(d = 0, carriers = ['e-', 'h+']) {
87
87
  }
88
88
 
89
89
  /**
90
- * A bath: a neutral composition `c` (mol/m³) held at the contact, its levels referred to the
91
- * charged `reference` species (the ion a reference electrode would sense, e.g. Cl⁻ for
92
- * Ag/AgCl), which is the terminal.
90
+ * A bath: a neutral composition `c` (mol/m³) held at the contact. With a `reference`, the charged
91
+ * species a reference electrode would sense (Cl⁻ for Ag/AgCl), its levels are referred to that
92
+ * species, which is the terminal. Without one, `bath(c, drive)`, the terminal voltage is the
93
+ * bath's φ: an ideal salt bridge, the convention of membrane potentials.
93
94
  * @param {Record<string, number>} c
94
- * @param {string} reference
95
+ * @param {string | number | object} [reference] the reference species, or the drive
95
96
  * @param {number | object} [drive]
96
97
  * @param {{ offset?: number }} [opts] where the reference species sits relative to the terminal
97
98
  * voltage, V_ref = V + offset (V)
98
99
  */
99
100
  export function bath(c, reference, d = 0, { offset } = {}) {
101
+ if (typeof reference !== 'string') return { ...drive(reference ?? 0, 'bath'), bath: { c: { ...c } } };
100
102
  return { ...drive(d, 'bath'), bath: { c: { ...c }, reference, ...(offset === undefined ? {} : { offset }) } };
101
103
  }
102
104