driftlet 0.12.0 → 0.14.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.14.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/checks.js CHANGED
@@ -29,7 +29,8 @@ const sig = (v, p = 3) => (v === 0 || !Number.isFinite(v) ? String(v) : Math.abs
29
29
  * zero; under light it's J = F(G − R), and it says where every carrier went.
30
30
  * - **conservation** (a transient): every closed, unreacting stretch keeps what it held plus
31
31
  * what came in.
32
- * - **grid** (a steady state, with `refine`, the default): the device solved again on a grid
32
+ * - **grid** (with `refine`, the default; in a transient, `ok: null` and a reminder, since the
33
+ * answer depends on the whole run): in a steady state, the device solved again on a grid
33
34
  * twice as fine everywhere, from this solution interpolated onto it. The current, each floating terminal's voltage and each
34
35
  * region's charge must agree to `tol` (relative, default 1e-2, a plot's accuracy; a benchmark
35
36
  * wants less). The discretisation is second order, so the change estimates this grid's error
@@ -67,6 +68,9 @@ export function check(device, sol, { refine = true, tol = 1e-2 } = {}) {
67
68
  const ledger = steady ? balance(device, sol) : null;
68
69
  items.push(ledger ?? conservation(sol, Math.max(...device.model.grid.area)));
69
70
  if (steady && refine) items.push(gridCheck(device, sol, tol, ledger.details.gross));
71
+ // A transient's answer depends on its whole history (and on drives set along the way), so
72
+ // only a run of its own on a finer grid can check it.
73
+ else if (refine) items.push({ name: 'grid', ok: null, summary: 'not checked in a transient: run it again on a grid twice as fine (hmin and hmax halved) and compare' });
70
74
  }
71
75
 
72
76
  const ok = sol.converged && items.every((it) => it.ok !== false);
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);
@@ -55,6 +56,49 @@ function positive(v, path) {
55
56
  return v;
56
57
  }
57
58
 
59
+ // Where a region's nodes miss a starting profile worst: the profile at each of its points
60
+ // against the line between the nodes either side, as a fraction of its range over the region
61
+ // (the start is the profile sampled at the nodes). Empty where they miss it by under 2%.
62
+ function unresolved(p, grid, r) {
63
+ const at = (t) => {
64
+ const { x, values: v } = p, last = x.length - 1;
65
+ if (t <= x[0]) return v[0];
66
+ if (t >= x[last]) return v[last];
67
+ let k = 0;
68
+ while (x[k + 1] < t) k++;
69
+ return v[k] + ((v[k + 1] - v[k]) * (t - x[k])) / (x[k + 1] - x[k]);
70
+ };
71
+ const g0 = grid.regionStart[r], g1 = grid.regionEnd[r], a = grid.x[g0], b = grid.x[g1];
72
+ let lo = Math.min(at(a), at(b)), hi = Math.max(at(a), at(b));
73
+ p.x.forEach((t, k) => {
74
+ if (t > a && t < b) (lo = Math.min(lo, p.values[k])), (hi = Math.max(hi, p.values[k]));
75
+ });
76
+ if (!(hi > lo)) return {};
77
+ const missAt = (t, xl, xr) => {
78
+ const cl = at(xl), cr = at(xr);
79
+ return Math.abs(at(t) - (cl + ((cr - cl) * (t - xl)) / (xr - xl))) / (hi - lo);
80
+ };
81
+ let worst = {}, g = g0;
82
+ p.x.forEach((t) => {
83
+ if (!(t > a && t < b)) return;
84
+ while (grid.x[g + 1] < t) g++;
85
+ const off = missAt(t, grid.x[g], grid.x[g + 1]);
86
+ if (off > 0.02 && !(off <= worst.off)) worst = { x: t, h: grid.x[g + 1] - grid.x[g], off };
87
+ });
88
+ if (!worst.off) return worst;
89
+ // The cells that would resolve it there: uniform ones, finer until they miss it by under 2%
90
+ // however they fall on it. (Cells wider than a step miss it by about as much at any size, so
91
+ // the miss isn't extrapolated.)
92
+ const near = Array.from(p.x).filter((t) => Math.abs(t - worst.x) <= 5 * worst.h);
93
+ const misses = (h) => Math.max(...[0, 0.25, 0.5, 0.75].flatMap((f) => near.map((t) => {
94
+ const xl = worst.x + h * (Math.floor((t - worst.x) / h - f) + f);
95
+ return missAt(t, xl, xl + h);
96
+ })));
97
+ worst.want = worst.h / 2;
98
+ while (misses(worst.want) > 0.02 && worst.want > worst.h * 1e-3) worst.want /= Math.SQRT2;
99
+ return worst;
100
+ }
101
+
58
102
  // A profile against the device's x: { x, values }, piecewise linear between its points and
59
103
  // constant beyond its ends (read with profileAt).
60
104
  function profile(v, path, what, ok, okText) {
@@ -325,6 +369,18 @@ export function normalizeDevice(def) {
325
369
  } catch (err) {
326
370
  throw new DeviceError(`grid: ${err.message}`);
327
371
  }
372
+ // A starting profile steeper than the cells under it starts as something else, silently: a
373
+ // liquid junction's potential then looks as if it grows while the junction forms.
374
+ regions.forEach((reg, r) => {
375
+ const missed = reg.c0Profile.map((p, i) => p && { i, ...unresolved(p, grid, r) }).filter((m) => m && m.off);
376
+ if (missed.length === 0) return;
377
+ const miss = missed.reduce((a, m) => (m.off > a.off ? m : a));
378
+ warnings.push(
379
+ `regions[${r}].c0 (${missed.map((m) => species[m.i].name).join(', ')}): the grid doesn't resolve the starting profile ` +
380
+ `(at x = ${miss.x.toExponential(2)} m, nodes ${miss.h.toExponential(1)} m apart miss it by ${Math.round(100 * miss.off)}% of its range), ` +
381
+ `so the start isn't the one given; cells of about ${miss.want.toExponential(0)} m there would resolve it (hmax, or a region boundary there to grade toward)`,
382
+ );
383
+ });
328
384
  const geometry = normalizeGeometry(def.geometry);
329
385
  if (geometry.type !== 'planar') {
330
386
  regions.forEach((reg, r) => need(reg.velocity === 0, `regions[${r}].velocity: flow is for a planar device (a uniform velocity through a varying cross-section wouldn't conserve the liquid)`));
@@ -669,7 +725,7 @@ function normalizeGeometry(g) {
669
725
  // the window, `area` (m²/m³) of it per volume.
670
726
  function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT) {
671
727
  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']);
728
+ fields(pdef, path, ['name', 'region', 'from', 'to', 'V', 'I', 'R', 'terminal', 'species', 'reactions', 'area', 'surface', 'capacitance', 'gates', 'bath']);
673
729
  let r;
674
730
  if (Number.isInteger(pdef.region)) r = pdef.region;
675
731
  else r = regions.findIndex((reg) => reg.name === pdef.region);
@@ -691,9 +747,27 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
691
747
  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
748
  const capacitance = portCapacitance(pdef, path, mat);
693
749
  // Gates (a membrane's channels): they follow the voltage across the port's capacitance.
694
- const gates = normalizeGates(pdef.gates, path, mat, mat);
750
+ const gates = normalizeGates(pdef.gates, path, mat, mat, "the port's capacitance");
695
751
  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
752
  need(pdef.species === undefined ? reactions.length > 0 || capacitance !== null : isObject(pdef.species), `${path}.species must map species names to port links`);
753
+ // A bath: the outside's composition. The port's V is then the outside's φ, and each linked
754
+ // species' level there, against it, follows from the composition through the window's
755
+ // statistics, (μ° + RT ζ)/(zF) (or μ for a neutral one), unless its link gives one. Only the
756
+ // linked species' levels are taken, so it needn't list (or balance) the rest.
757
+ let bathLevel = null;
758
+ if (pdef.bath !== undefined) {
759
+ need(isObject(pdef.bath) && isObject(pdef.bath.c), `${path}.bath must be { c: { species: concentration } }`);
760
+ fields(pdef.bath, `${path}.bath`, ['c']);
761
+ const cb = new Float64Array(species.length);
762
+ for (const [sname, v] of Object.entries(pdef.bath.c)) {
763
+ need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
764
+ const i = speciesIndex.get(sname);
765
+ need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${reg.name} (material '${mat.name}'), so its level outside means nothing here`);
766
+ cb[i] = positive(v, `${path}.bath.c.${sname}`);
767
+ }
768
+ const zb = bathZeta(mat, cb, reg.background);
769
+ bathLevel = (i) => (cb[i] > 0 ? mat.mu0[i] + RT * zb[i] : undefined); // μ̄ − zFφ outside
770
+ }
697
771
  const links = species.map(() => ({ type: 'blocked' }));
698
772
  for (const [sname, raw] of Object.entries(pdef.species ?? {})) {
699
773
  const lpath = `${path}.species.${sname}`;
@@ -708,14 +782,15 @@ function normalizePort(pdef, path, regions, materials, species, speciesIndex, RT
708
782
  const z = species[i].z;
709
783
  // The outside level: an offset from V for charged species, an absolute μ for neutral ones.
710
784
  let level;
785
+ const fromBath = bathLevel?.(i);
711
786
  if (z === 0) {
712
787
  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)`) };
788
+ level = { mu: finite(link.mu ?? fromBath, `${lpath}.mu (the outside μ, J/mol${bathLevel ? `; or give '${sname}' in the bath` : ''})`) };
714
789
  } else {
715
790
  need(link.type !== 'exchange', `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
716
791
  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`);
792
+ const offset = link.offset ?? (fromBath !== undefined ? fromBath / (z * FARADAY) : !bathLevel && terminal === i ? 0 : undefined);
793
+ 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
794
  level = { offset: finite(offset, `${lpath}.offset`) };
720
795
  }
721
796
  // On a metal, G is a lumped conductance per area (S/m²), spread over its thickness.
@@ -968,10 +1043,10 @@ function gatedBy(raw, gates, lpath, where) {
968
1043
  return [q, p];
969
1044
  });
970
1045
  }
971
- function normalizeGates(raw, where, matL, matR) {
1046
+ function normalizeGates(raw, where, matL, matR, across = 'the face') {
972
1047
  if (raw === undefined) return [];
973
1048
  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`);
1049
+ need(!matL.phiFree && !matR.phiFree, `${where}.gates: a gate follows the voltage across ${across}, which needs φ there`);
975
1050
  return Object.entries(raw).map(([name, gdef]) => {
976
1051
  const gpath = `${where}.gates.${name}`;
977
1052
  need(isObject(gdef), `${gpath} must be { alpha, beta }`);
@@ -1153,6 +1228,22 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1153
1228
  links[i] = { type: 'conductance', G: positive(link.G, `${lpath}.G`), offset: finite(offset, `${lpath}.offset`) };
1154
1229
  continue;
1155
1230
  }
1231
+ if (link.type === 'velocity') {
1232
+ // A surface velocity: N_in = v (c_eq − c), c_eq the edge's concentration in equilibrium
1233
+ // with the outside level (thermionic emission over a barrier, a contact's surface
1234
+ // recombination velocity), v c (e^((μ̄_out − μ̄)/RT) − 1) for any statistics.
1235
+ const v = positive(link.v, `${lpath}.v (m/s)`);
1236
+ if (species[i].z === 0) {
1237
+ need(link.offset === undefined, `${lpath}: a neutral species' outside level is its mu (J/mol), not an offset`);
1238
+ links[i] = { type: 'velocity', v, mu: finite(link.mu, `${lpath}.mu (the outside μ, J/mol)`) };
1239
+ continue;
1240
+ }
1241
+ need(link.mu === undefined, `${lpath}: a charged species' outside level is an offset from the terminal voltage, not a mu`);
1242
+ const offset = link.offset ?? (terminal === i ? 0 : undefined);
1243
+ need(offset !== undefined, `${lpath}.offset: give V_i − V_terminal (V) of the outside reservoir; only the terminal species defaults to 0`);
1244
+ links[i] = { type: 'velocity', v, offset: finite(offset, `${lpath}.offset`) };
1245
+ continue;
1246
+ }
1156
1247
  if (link.type === 'exchange') {
1157
1248
  // A neutral species exchanging with the outside at its μ: N_in = k (μ_out − μ)/RT.
1158
1249
  need(species[i].z === 0, `${lpath}: a charged species exchanges by { type: 'conductance', G }`);
@@ -1184,12 +1275,19 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1184
1275
  const bath = cdef.bath;
1185
1276
  need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
1186
1277
  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);
1278
+ // Without a reference, V is the bath's φ (an ideal salt bridge: the membrane potential's
1279
+ // convention), and the terminal is the first charged species in it.
1280
+ const byPhi = bath.reference === undefined;
1281
+ if (byPhi) need(bath.offset === undefined, `${path}.bath.offset: an offset places the reference species, and this bath has none (its V is its φ)`);
1282
+ else need(speciesIndex.has(bath.reference), `${path}.bath.reference: unknown species ${JSON.stringify(bath.reference)}${known(speciesIndex)}`);
1283
+ const r = byPhi
1284
+ ? species.findIndex((sp) => sp.z !== 0 && bath.c[sp.name] > 0)
1285
+ : speciesIndex.get(bath.reference);
1286
+ need(r >= 0, `${path}.bath: the bath needs a charged species`);
1189
1287
  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;
1288
+ need(bath.c[species[r].name] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
1289
+ need(byPhi || cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
1290
+ terminal = byPhi && terminal !== null ? terminal : r;
1193
1291
  let charge = region.fixedCharge / FARADAY, scale = Math.abs(charge);
1194
1292
  const cb = new Float64Array(species.length);
1195
1293
  for (const [sname, v] of Object.entries(bath.c)) {
@@ -1205,7 +1303,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1205
1303
  const zb = bathZeta(mat, cb, region.background);
1206
1304
  const level = (i) => mat.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
1207
1305
  const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
1208
- const beta = refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
1306
+ const beta = byPhi ? 0 : refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
1209
1307
  for (let i = 0; i < species.length; i++) {
1210
1308
  if (!(cb[i] > 0)) continue;
1211
1309
  links[i] =
package/src/index.js CHANGED
@@ -166,7 +166,12 @@ export class Device {
166
166
  * @returns {Solution}
167
167
  */
168
168
  solve(opts) {
169
- return makeSolution(this.solver, this.solver.solveSteady(opts));
169
+ // A solve that fails reports the state it failed in, and leaves the device as it was: a
170
+ // device with no steady state (a host filling at a current) can still advance() from there.
171
+ const snap = this.solver._snapshot();
172
+ const sol = makeSolution(this.solver, this.solver.solveSteady(opts));
173
+ if (!sol.converged) this.solver._restore(snap);
174
+ return sol;
170
175
  }
171
176
 
172
177
  /**
package/src/kit.js CHANGED
@@ -15,6 +15,7 @@ export { describe, unitWarnings } from './describe.js';
15
15
  export { recorder } from './recorder.js';
16
16
  export { henderson, planck } from './junction.js';
17
17
  export { check } from './checks.js';
18
+ export { particles } from './particles.js';
18
19
  export { polarization } from './polarization.js';
19
20
  export { perovskiteCell, PEROVSKITE_SCANS, hysteresis } from './devices.js';
20
21
  export { IONS, H2O, WATER_EPSR, SEMICONDUCTORS, METALS, aqueous, semiconductor, metal } from './data.js';