driftlet 0.14.0 → 0.16.4

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/src/device.js CHANGED
@@ -219,9 +219,12 @@ export function normalizeDevice(def) {
219
219
  const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
220
220
  species.forEach((sp, i) => {
221
221
  const reacting = (def.ports ?? []).some((p) => Array.isArray(p?.reactions) && p.reactions.length > 0);
222
+ // (a port naming it as its terminal without reactions: a capacitance alone needs none)
223
+ const named = (def.ports ?? []).find((p) => p?.terminal === sp.name);
222
224
  need(
223
225
  materials.some((m) => m.present[i]) || outside(sp),
224
- `species '${sp.name}' is not present in any material` + (reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
226
+ `species '${sp.name}' is not present in any material` +
227
+ (named ? `: it is the terminal of port ${JSON.stringify(named.name ?? '')}, which can hold a carrier from outside the device only with reactions. A port with only a capacitance needs no terminal: drop it (and the species)` : reacting ? " (an electrode's carrier can instead be a reacting port's terminal)" : ''),
225
228
  );
226
229
  });
227
230
 
@@ -1158,8 +1161,10 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
1158
1161
  return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
1159
1162
  }
1160
1163
 
1164
+ // A species present on both sides crosses freely; one that can't move on either (D = 0, trap
1165
+ // states, say) is blocked: held level, its two edge boxes would pass whatever flux that takes.
1161
1166
  function defaultInterfaceLinks(matL, matR, species) {
1162
- return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] ? 'equilibrium' : 'blocked' }));
1167
+ return species.map((_, i) => ({ type: matL.present[i] && matR.present[i] && (matL.D[i] > 0 || matR.D[i] > 0) ? 'equilibrium' : 'blocked' }));
1163
1168
  }
1164
1169
 
1165
1170
  function transferCoefficient(v, path) {
@@ -1197,6 +1202,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1197
1202
  const links = species.map(() => ({ type: 'blocked' }));
1198
1203
  let phi = { type: 'neutral' };
1199
1204
  let terminal = null;
1205
+ let she = null; // a bath's SHE level against V
1200
1206
  if (cdef === undefined || cdef === null) return { drive: normalizeDrive({}, path), species: links, phi, terminal };
1201
1207
  need(isObject(cdef), `${path} must be an object`);
1202
1208
  fields(cdef, path, ['V', 'I', 'R', 'terminal', 'species', 'bath', 'phi', 'zeroCharge']);
@@ -1274,7 +1280,13 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1274
1280
  need(cdef.species === undefined, `${path}: give either bath or species links, not both`);
1275
1281
  const bath = cdef.bath;
1276
1282
  need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
1277
- fields(bath, `${path}.bath`, ['c', 'reference', 'offset']);
1283
+ fields(bath, `${path}.bath`, ['c', 'reference', 'offset', 'material']);
1284
+ // The bath is the end material at that composition (balancing any fixed charge there), unless
1285
+ // it names a material of its own: a solution outside an ion exchanger, a doped or host region,
1286
+ // whose levels it sets there through a Donnan step at the end node.
1287
+ const own = bath.material !== undefined;
1288
+ const bm = own ? materials.find((m) => m.name === bath.material) : mat;
1289
+ need(bm && !bm.conductor && !bm.phiFree, `${path}.bath.material: ${JSON.stringify(bath.material)} is not a material with ions in it (one of ${materials.filter((m) => !m.conductor).map((m) => m.name).join(', ')})`);
1278
1290
  // Without a reference, V is the bath's φ (an ideal salt bridge: the membrane potential's
1279
1291
  // convention), and the terminal is the first charged species in it.
1280
1292
  const byPhi = bath.reference === undefined;
@@ -1288,24 +1300,35 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1288
1300
  need(bath.c[species[r].name] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
1289
1301
  need(byPhi || cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
1290
1302
  terminal = byPhi && terminal !== null ? terminal : r;
1291
- let charge = region.fixedCharge / FARADAY, scale = Math.abs(charge);
1303
+ const fixed = own ? 0 : region.fixedCharge / FARADAY;
1304
+ let charge = fixed, scale = Math.abs(charge);
1292
1305
  const cb = new Float64Array(species.length);
1293
1306
  for (const [sname, v] of Object.entries(bath.c)) {
1294
1307
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
1295
1308
  const i = speciesIndex.get(sname);
1296
- need(mat.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from the end material '${mat.name}'`);
1309
+ need(bm.present[i], `${path}.bath.c.${sname}: '${sname}' is absent from ${own ? 'the bath' : 'the end'} material '${bm.name}'`);
1297
1310
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
1298
1311
  charge += species[i].z * cb[i];
1299
1312
  scale += Math.abs(species[i].z) * cb[i];
1300
1313
  }
1301
- need(Math.abs(charge) <= 1e-9 * scale, `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`);
1314
+ need(
1315
+ Math.abs(charge) <= 1e-9 * scale,
1316
+ fixed !== 0
1317
+ ? `${path}.bath: the bath is the end material '${mat.name}' at this composition, and with its fixed charge (${fixed} mol/m³ of charge) it is not neutral (net ${charge}). ` +
1318
+ `For a solution outside a charged region, give the solution's material (bath.material), whose composition is then neutral on its own`
1319
+ : `${path}.bath: composition is not neutral (net ${charge} mol/m³ of charge)`,
1320
+ );
1302
1321
  // The reference species pins the bath's φ; every other species follows from composition.
1303
- const zb = bathZeta(mat, cb, region.background);
1304
- const level = (i) => mat.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
1322
+ const zb = bathZeta(bm, cb, own ? 0 : region.background);
1323
+ const level = (i) => bm.mu0[i] + RT * zb[i]; // μ̄ − zFφ_bath
1305
1324
  const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
1306
1325
  const beta = byPhi ? 0 : refOffset - level(r) / (species[r].z * FARADAY); // φ_bath − V
1326
+ // Where the bath's SHE level sits against V (for describe()): φ + μ°_H⁺/F with H⁺ in the
1327
+ // bath, else φ on the usual tables' convention (μ°_H⁺ = 0), which then is only a reading.
1328
+ const h = speciesIndex.get('H+');
1329
+ she = { shift: beta + (h !== undefined && bm.present[h] ? bm.mu0[h] / FARADAY : 0), exact: h !== undefined && bm.present[h], reference: !byPhi };
1307
1330
  for (let i = 0; i < species.length; i++) {
1308
- if (!(cb[i] > 0)) continue;
1331
+ if (!(cb[i] > 0) || !mat.present[i]) continue; // (a bath of its own may hold species the end lacks)
1309
1332
  links[i] =
1310
1333
  species[i].z === 0
1311
1334
  ? { type: 'equilibrium', mu: level(i) }
@@ -1343,5 +1366,5 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1343
1366
  if (terminal !== null) {
1344
1367
  need(links[terminal].type !== 'blocked', `${path}.terminal: '${species[terminal].name}' is blocked at this contact`);
1345
1368
  }
1346
- return { drive, species: links, phi, terminal };
1369
+ return { drive, species: links, phi, terminal, ...(she ? { she } : {}) };
1347
1370
  }
package/src/index.js CHANGED
@@ -63,8 +63,8 @@ export class Device {
63
63
  }
64
64
 
65
65
  /**
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. Ports are patched by name,
66
+ * Change part of the definition (deep-merged). A contact given V or I drops the other (and
67
+ * given I, any series R), so `{ contacts: { right: { I: 0 } } }` switches it to open circuit. Ports are patched by name,
68
68
  * `{ ports: { gate: { V: 0.2 } } }` (an array replaces them all). A change to the terminals'
69
69
  * drives alone (V, I, R) is applied in place, cheaply. Otherwise the device is rebuilt, keeping the current state as
70
70
  * the warm start when the grid and species are unchanged (else restarting from the regions' c0).
@@ -73,7 +73,8 @@ export class Device {
73
73
  */
74
74
  set(patch) {
75
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);
76
+ // (A current drives a terminal alone: given I, a contact drops V and any series R too.)
77
+ const redrive = (c) => (c && typeof c === 'object' && !Array.isArray(c) ? ('I' in c && !('V' in c) ? { ...c, V: undefined, ...('R' in c ? {} : { R: undefined }) } : 'V' in c && !('I' in c) ? { ...c, I: undefined } : c) : c);
77
78
  for (const side of ['left', 'right']) {
78
79
  const c = patch?.contacts?.[side];
79
80
  if (c && typeof c === 'object') patch = { ...patch, contacts: { ...patch.contacts, [side]: redrive(c) } };
@@ -106,15 +107,27 @@ export class Device {
106
107
  this.def = def;
107
108
  this.model = model;
108
109
  this._solver = solver;
109
- if (old && old.u.length === solver.u.length && old.n === solver.n) {
110
- solver.u.set(old.u);
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);
110
+ // The state carries over where the unknowns line up; on a new grid (the same regions, faces
111
+ // and terminals), interpolated onto it. (Started cold instead, a floating gate lost its
112
+ // charge to a refined grid.)
113
+ const sameUnknowns = old && old.u.length === solver.u.length && old.n === solver.n;
114
+ const regridded = old && !sameUnknowns && old.M === solver.M && old.n === solver.n && old.nFaces === solver.nFaces && old.terms.length === solver.terms.length && old.model.grid.regionStart.length === model.grid.regionStart.length;
115
+ if (sameUnknowns || regridded) {
116
+ if (sameUnknowns) {
117
+ solver.u.set(old.u);
118
+ solver.uLo.set(old.uLo);
119
+ // A node newly on an electrode's surface (a window moved, a surface added) starts at its
120
+ // starting coverages, not at whatever its slots held.
121
+ const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
122
+ for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
123
+ } else solver._warmFrom(old);
116
124
  solver.computeConcentrations();
117
125
  solver.time = old.time;
126
+ // Held sources read at the same time as before, and an adaptive transient goes on at the
127
+ // step size it had reached (its order restarts, as at any change of the equations).
128
+ solver.sourceTime = old.sourceTime;
129
+ solver._refreshSources();
130
+ solver.dtNext = old.dtNext;
118
131
  solver.contactDEnd = old.contactDEnd;
119
132
  if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
120
133
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
package/src/particles.js CHANGED
@@ -47,53 +47,72 @@ const NONE = 0, DOT = 1, OUTSIDE = 2; // nothing (closed); a cell of dots; a sea
47
47
  * @param {number} [opts.dots] about how many dots per species (default 200), for the default weight
48
48
  * @param {number|object} [opts.weight] amount per dot, mol/m²: one number for every species (so
49
49
  * their dots compare), or { name: weight }; default, each species' total over `dots`
50
- * @param {number} [opts.cap] the most dots a cell holds on average before it's a sea (default ∞)
50
+ * @param {number} [opts.cap] the most dots a cell of the asked-for width holds on average before
51
+ * it's a sea (a density: a wider cell holds more; default ∞)
51
52
  * @param {number} [opts.cells] about how many cells across the device (default 120)
52
53
  * @param {() => number} [opts.random] a uniform random source in [0, 1) (default Math.random)
53
54
  */
54
55
  export function particles(sol, { species, dots = 200, weight, cap = Infinity, cells = 120, random = Math.random } = {}) {
55
56
  const names = species ?? sol.species.map((s) => s.name).filter((nm) => sol.c[nm].some((v) => v > 0));
56
- const nn = sol.x.length, x = sol.x;
57
+ for (const nm of names) if (!sol.c[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in the solution`);
58
+ if (!(dots > 0 && Number.isFinite(dots))) throw new RangeError(`particles: dots must be a positive number, got ${dots}`);
59
+ if (!(cells >= 1 && Number.isFinite(cells))) throw new RangeError(`particles: cells must be a number from 1 up, got ${cells}`);
60
+ if (!(cap >= 0)) throw new RangeError(`particles: cap must be a number from 0 up, got ${cap}`);
61
+ const MAX = 1e6; // the most dots a swarm will draw
57
62
 
58
63
  // The lattice: each region's nodes grouped into runs of node boxes about L/cells wide, so a
59
64
  // cell boundary is a segment, where the solution's flux is. (A metal's nodes, with no
60
- // concentration, are left out.)
61
- const box = Float64Array.from(x, (_, g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? x[g] - x[g - 1] : 0) + (g < nn - 1 && sol.region[g + 1] === sol.region[g] ? x[g + 1] - x[g] : 0)) / 2);
62
- const target = (x[nn - 1] - x[0]) / cells;
63
- const lattice = [];
64
- sol.regions.forEach((_, r) => {
65
- const g0 = sol.region.indexOf(r), g1 = sol.region.lastIndexOf(r);
66
- if (names.every((nm) => !(sol.c[nm][g0] >= 0))) return;
67
- const start = lattice.length;
68
- let a = g0, w = 0;
69
- for (let g = g0; g <= g1; g++) {
70
- w += box[g];
71
- if (w >= target || g === g1) {
72
- lattice.push({ a, b: g, region: r, h: w });
73
- [a, w] = [g + 1, 0];
65
+ // concentration, are left out.) Drawn again for a solution on another grid.
66
+ let x, nn, box, lattice, nc, at, target;
67
+ function draw(sol) {
68
+ x = Float64Array.from(sol.x);
69
+ nn = x.length;
70
+ box = Float64Array.from(x, (_, g) => ((g > 0 && sol.region[g - 1] === sol.region[g] ? x[g] - x[g - 1] : 0) + (g < nn - 1 && sol.region[g + 1] === sol.region[g] ? x[g + 1] - x[g] : 0)) / 2);
71
+ target = (x[nn - 1] - x[0]) / cells;
72
+ lattice = [];
73
+ sol.regions.forEach((_, r) => {
74
+ const g0 = sol.region.indexOf(r), g1 = sol.region.lastIndexOf(r);
75
+ if (names.every((nm) => !(sol.c[nm][g0] >= 0))) return;
76
+ const start = lattice.length;
77
+ let a = g0, w = 0;
78
+ for (let g = g0; g <= g1; g++) {
79
+ w += box[g];
80
+ // a run ends where one more node's box would take it farther from the target width
81
+ if (g === g1 || w + box[g + 1] - target > target - w) {
82
+ lattice.push({ a, b: g, region: r, h: w });
83
+ [a, w] = [g + 1, 0];
84
+ }
74
85
  }
86
+ const last = lattice.length - 1;
87
+ if (last > start && lattice[last].h < target / 2) {
88
+ // a thin last cell joins the one before it
89
+ lattice[last - 1].b = lattice[last].b;
90
+ lattice[last - 1].h += lattice[last].h;
91
+ lattice.pop();
92
+ }
93
+ });
94
+ for (const cl of lattice) {
95
+ cl.x0 = x[cl.a] - (cl.a > 0 && sol.region[cl.a - 1] === cl.region ? (x[cl.a] - x[cl.a - 1]) / 2 : 0);
96
+ cl.x1 = cl.x0 + cl.h;
97
+ let mid = cl.a;
98
+ for (let g = cl.a; g <= cl.b; g++) if (Math.abs(x[g] - (cl.x0 + cl.x1) / 2) < Math.abs(x[mid] - (cl.x0 + cl.x1) / 2)) mid = g;
99
+ cl.mid = mid;
75
100
  }
76
- const last = lattice.length - 1;
77
- if (last > start && lattice[last].h < target / 2) {
78
- // a thin last cell joins the one before it
79
- lattice[last - 1].b = lattice[last].b;
80
- lattice[last - 1].h += lattice[last].h;
81
- lattice.pop();
82
- }
83
- });
84
- for (const cl of lattice) {
85
- cl.x0 = x[cl.a] - (cl.a > 0 && sol.region[cl.a - 1] === cl.region ? (x[cl.a] - x[cl.a - 1]) / 2 : 0);
86
- cl.x1 = cl.x0 + cl.h;
87
- let mid = cl.a;
88
- for (let g = cl.a; g <= cl.b; g++) if (Math.abs(x[g] - (cl.x0 + cl.x1) / 2) < Math.abs(x[mid] - (cl.x0 + cl.x1) / 2)) mid = g;
89
- cl.mid = mid;
101
+ nc = lattice.length;
102
+ // where each boundary is: k between cells k − 1 and k
103
+ at = Float64Array.from({ length: nc + 1 }, (_, k) => (k < nc ? lattice[k].x0 : lattice[nc - 1].x1));
104
+ swarm.cells = lattice.map(({ x0, x1, region }) => ({ x0, x1, region }));
105
+ swarm.dots = [];
106
+ for (const nm of names) swarm.crossed[nm] = { up: new Float64Array(nc + 1), down: new Float64Array(nc + 1) };
107
+ for (const nm of names) delete S[nm];
90
108
  }
91
- const nc = lattice.length;
92
- // where each boundary is: k between cells k − 1 and k
93
- const at = Float64Array.from({ length: nc + 1 }, (_, k) => (k < nc ? lattice[k].x0 : lattice[nc - 1].x1));
109
+ // A sea where a cell would hold more than cap dots for each target width it spans: a density,
110
+ // so a wider cell among the rest doesn't become a sea alone.
111
+ const dense = (m, K) => m > (cap * lattice[K].h) / target;
112
+ const sameGrid = (s) => s.x.length === nn && s.x.every((v, g) => v === x[g]);
94
113
 
95
114
  const swarm = {
96
- cells: lattice.map(({ x0, x1, region }) => ({ x0, x1, region })),
115
+ cells: [],
97
116
  species: names,
98
117
  weight: {}, // mol/m² per dot
99
118
  sea: {}, // per species, per cell: 1 where it's a sea
@@ -123,7 +142,7 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
123
142
  const c = sol.c[nm], mu = sol.mu[nm], D = sol.D[nm], J = sol.flux[nm], RT = GAS_CONSTANT * sol.T;
124
143
  const N = amounts(sol, nm), w = swarm.weight[nm];
125
144
  const on = Uint8Array.from(lattice, (cl) => (c[cl.mid] >= 0 ? 1 : 0));
126
- const sea = Uint8Array.from(N, (n, K) => (on[K] && n / w > cap ? 1 : 0));
145
+ const sea = Uint8Array.from(N, (n, K) => (on[K] && dense(n / w, K) ? 1 : 0));
127
146
  const psi = Float64Array.from(lattice, (cl, K) => mu[cl.mid] / RT - Math.log(N[K] / cl.h));
128
147
  // Boundary k: what lies to each side, its one-way fluxes (mol/(m²·s)), and whether it's a face
129
148
  // with one-way fluxes of its own (a membrane), whose crossings between seas are events.
@@ -193,7 +212,9 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
193
212
  if (rest > 0) made[K] += rest;
194
213
  else unmade[K] -= rest;
195
214
  }
196
- return { N, on, sea, left, right, face, up, down, made, unmade, time: sol.time };
215
+ // Where in a cell a dot sits: in a node's box, chosen by its share of the cell's amount.
216
+ const share = Float64Array.from(x, (_, g) => (c[g] > 0 ? c[g] * box[g] : 0));
217
+ return { N, on, sea, left, right, face, up, down, made, unmade, share, time: sol.time };
197
218
  }
198
219
 
199
220
  /**
@@ -202,10 +223,17 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
202
223
  * steady states, which has no path in time to follow), each cell's dots are thinned or added to
203
224
  * by the change in what it's expected to hold, m → m′: each kept with probability m′/m if it
204
225
  * falls, Poisson(m′ − m) more if it rises, which takes a Poisson sample of the one into a
205
- * Poisson sample of the other, moving as few dots as can be.
226
+ * Poisson sample of the other, moving as few dots as can be. A solution on another grid (a
227
+ * resized device) redraws the lattice, and the dots are placed afresh.
206
228
  */
207
229
  function update(next, { resample = false } = {}) {
230
+ if (!next?.x || !next.c) throw new TypeError('particles: update takes a solution');
231
+ for (const nm of names) if (!next.c[nm]) throw new RangeError(`particles: the new solution has no species ${JSON.stringify(nm)}`);
208
232
  sol = next;
233
+ if (!sameGrid(sol)) draw(sol); // (a resized device: the dots placed afresh)
234
+ let total = 0;
235
+ for (const nm of names) total += amounts(sol, nm).reduce((a, n, K) => a + (dense(n / swarm.weight[nm], K) ? 0 : n), 0) / swarm.weight[nm];
236
+ if (total > MAX) throw new RangeError(`particles: this would draw ${total.toPrecision(2)} dots; give a larger weight, or a cap (dense cells as seas)`);
209
237
  for (const nm of names) {
210
238
  const was = S[nm];
211
239
  S[nm] = fields(sol, nm, was);
@@ -229,10 +257,23 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
229
257
 
230
258
  /** The dots expected in each cell for a species, at the solution's concentrations. */
231
259
  function expected(nm) {
260
+ if (!S[nm]) throw new RangeError(`particles: no species ${JSON.stringify(nm)} in this swarm (${names.join(', ')})`);
232
261
  return Float64Array.from(S[nm].N, (n) => n / swarm.weight[nm]);
233
262
  }
234
263
 
235
- const dot = (nm, K) => ({ species: nm, cell: K, x: lattice[K].x0 + random() * lattice[K].h });
264
+ // A place in cell K for a dot of species nm: a node's box, by its share of the amount, then
265
+ // anywhere in that box. (A dot follows the profile within its cell, as at a depletion edge.)
266
+ function place(nm, K) {
267
+ const cl = lattice[K], share = S[nm]?.share;
268
+ let g = cl.a;
269
+ if (share && S[nm].N[K] > 0) {
270
+ let u = random() * S[nm].N[K];
271
+ while (g < cl.b && (u -= share[g]) > 0) g++;
272
+ } else return cl.x0 + random() * cl.h;
273
+ const lo = g > cl.a ? (x[g - 1] + x[g]) / 2 : cl.x0, hi = g < cl.b ? (x[g] + x[g + 1]) / 2 : cl.x1;
274
+ return lo + random() * (hi - lo);
275
+ }
276
+ const dot = (nm, K) => ({ species: nm, cell: K, x: place(nm, K) });
236
277
 
237
278
  // A Poisson count of mean m (a normal one for large m).
238
279
  const gauss = () => Math.sqrt(-2 * Math.log(1 - random())) * Math.cos(2 * Math.PI * random());
@@ -246,13 +287,36 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
246
287
 
247
288
  /**
248
289
  * Move the dots on by dt (s, the device's time): each hops, and dots are made and unmade, at
249
- * the solution's rates. A dot hops at most maxHops times in a step.
290
+ * the solution's rates. A cell a dot would hop out of more than maxHops/4 times in the step is
291
+ * a sea for the step (whatever crosses its sides counted at the one-way fluxes, dots walking in
292
+ * absorbed), then drawn afresh, a new sample of what it holds: so a step much longer than the
293
+ * hop time costs little, and stays a sample of the solution. A dt that's NaN or negative (a
294
+ * first frame, a clock that stepped back) moves nothing.
250
295
  */
251
296
  function step(dt, { maxHops = 200 } = {}) {
297
+ // (A first frame or a clock stepping back, NaN or negative, moves nothing.)
252
298
  swarm.events = [];
299
+ if (dt === Infinity) throw new RangeError('particles: step(dt) takes a finite time (s), got Infinity');
300
+ if (!(dt > 0)) return swarm;
301
+ const fast = {}, saved = {};
302
+ for (const nm of names) {
303
+ const F = S[nm];
304
+ fast[nm] = Uint8Array.from(F.N, (N, K) => {
305
+ if (!F.on[K] || F.sea[K] || !(N > 0)) return 0;
306
+ const rate = ((F.right[K + 1] ? F.up[K + 1] : 0) + (F.left[K] ? F.down[K] : 0) + F.unmade[K]) / N;
307
+ return rate * dt > maxHops / 4 ? 1 : 0;
308
+ });
309
+ if (!fast[nm].some(Boolean)) continue;
310
+ // For this step, those cells are seas.
311
+ saved[nm] = [F.sea, F.left, F.right];
312
+ F.sea = Uint8Array.from(F.sea, (v, K) => v | fast[nm][K]);
313
+ F.left = Uint8Array.from(F.left, (v, k) => (v && k > 0 && fast[nm][k - 1] ? OUTSIDE : v));
314
+ F.right = Uint8Array.from(F.right, (v, k) => (v && k < nc && fast[nm][k] ? OUTSIDE : v));
315
+ }
253
316
  // Births first, each at a random moment of the step: made in a cell, or arriving from beyond
254
317
  // (a sea, a contact, a metal). Each then moves for the rest of the step. And crossings
255
- // between seas through a face.
318
+ // between seas: through a face, as events; between cells that are seas only for this step,
319
+ // counted.
256
320
  const born = [];
257
321
  for (const nm of names) {
258
322
  const F = S[nm], w = swarm.weight[nm], cr = swarm.crossed[nm];
@@ -264,36 +328,46 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
264
328
  if (!(rate > 0)) continue;
265
329
  for (let j = poisson(rate * dt); j > 0; j--) {
266
330
  const u = random() * rate, d = dot(nm, K);
331
+ // (no 'in' from a cell that's a sea only for this step)
267
332
  if (u < fromLeft) {
268
333
  cr.up[K]++;
269
- swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
334
+ if (!fast[nm][K - 1]) swarm.events.push({ species: nm, kind: 'in', x: at[K], dir: 1 });
270
335
  } else if (u < fromLeft + fromRight) {
271
336
  cr.down[K + 1]++;
272
- swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
337
+ if (!fast[nm][K + 1]) swarm.events.push({ species: nm, kind: 'in', x: at[K + 1], dir: -1 });
273
338
  } else swarm.events.push({ species: nm, kind: 'made', x: d.x });
274
339
  born.push([d, random() * dt]);
275
340
  }
276
341
  }
277
342
  for (let k = 0; k <= nc; k++) {
278
- if (!F.face[k] || F.left[k] !== OUTSIDE || F.right[k] !== OUTSIDE) continue;
343
+ if (F.left[k] !== OUTSIDE || F.right[k] !== OUTSIDE) continue;
344
+ const events = F.face[k] && !(fast[nm][k - 1] || fast[nm][k]);
345
+ if (!events && !(fast[nm][k - 1] || fast[nm][k])) continue;
279
346
  for (const [dir, flux, count] of [[1, F.up[k], cr.up], [-1, F.down[k], cr.down]]) {
280
- for (let j = poisson((flux / w) * dt); j > 0; j--) {
281
- count[k]++;
282
- swarm.events.push({ species: nm, kind: 'cross', x: at[k], dir });
283
- }
347
+ const n = poisson((flux / w) * dt);
348
+ count[k] += n;
349
+ if (events) for (let j = 0; j < n; j++) swarm.events.push({ species: nm, kind: 'cross', x: at[k], dir });
284
350
  }
285
351
  }
286
352
  }
287
353
  const keep = [];
288
- for (const d of swarm.dots) if (walk(d, dt, maxHops)) keep.push(d);
289
- for (const [d, t] of born) if (walk(d, dt - t, maxHops)) keep.push(d);
354
+ for (const d of swarm.dots) if (!fast[d.species][d.cell] && walk(d, dt, maxHops, fast[d.species])) keep.push(d);
355
+ for (const [d, t] of born) if (walk(d, dt - t, maxHops, fast[d.species])) keep.push(d);
290
356
  swarm.dots = keep;
357
+ // The step's seas drawn afresh, and back to cells of dots.
358
+ for (const nm of Object.keys(saved)) {
359
+ const F = S[nm], w = swarm.weight[nm];
360
+ [F.sea, F.left, F.right] = saved[nm];
361
+ fast[nm].forEach((f, K) => {
362
+ if (f) for (let j = poisson(F.N[K] / w); j > 0; j--) swarm.dots.push(dot(nm, K));
363
+ });
364
+ }
291
365
  swarm.time += dt;
292
366
  return swarm;
293
367
  }
294
368
 
295
369
  // A dot's hops for a time dt (Gillespie's: exact for rates held fixed): whether it's still in.
296
- function walk(d, dt, maxHops) {
370
+ function walk(d, dt, maxHops, fast) {
297
371
  const F = S[d.species], cr = swarm.crossed[d.species];
298
372
  let t = 0;
299
373
  for (let hop = 0; hop < maxHops; hop++) {
@@ -312,20 +386,22 @@ export function particles(sol, { species, dots = 200, weight, cap = Infinity, ce
312
386
  const k = u < r ? K + 1 : K, dir = u < r ? 1 : -1;
313
387
  (dir > 0 ? cr.up : cr.down)[k]++;
314
388
  if ((dir > 0 ? F.right[k] : F.left[k]) === OUTSIDE) {
315
- swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
389
+ if (!fast?.[K + dir]) swarm.events.push({ species: d.species, kind: 'out', x: at[k], dir });
316
390
  return false;
317
391
  }
318
392
  d.cell = K + dir;
319
- d.x = lattice[d.cell].x0 + random() * lattice[d.cell].h;
393
+ d.x = place(d.species, d.cell);
320
394
  }
321
395
  return true;
322
396
  }
323
397
 
324
- // Weights, then the first dots, placed by the concentration.
398
+ // The lattice and weights, then the first dots, placed by the concentration.
399
+ draw(sol);
325
400
  for (const nm of names) {
326
401
  const total = amounts(sol, nm).reduce((a, b) => a + b, 0);
327
- swarm.weight[nm] = typeof weight === 'number' ? weight : isObject(weight) && weight[nm] > 0 ? weight[nm] : total / dots;
328
- swarm.crossed[nm] = { up: new Float64Array(nc + 1), down: new Float64Array(nc + 1) };
402
+ const w = typeof weight === 'number' ? weight : isObject(weight) && weight[nm] !== undefined ? weight[nm] : total / dots;
403
+ if (!(w > 0 && Number.isFinite(w))) throw new RangeError(`particles: the weight of ${nm} (mol/m² per dot) must be a positive number, got ${w}` + (total > 0 ? '' : ' (it has nothing to draw)'));
404
+ swarm.weight[nm] = w;
329
405
  }
330
406
  return update(sol);
331
407
  }