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/README.md +12 -1
- package/docs/alignment.md +6 -3
- package/docs/data.md +3 -1
- package/docs/device.md +102 -33
- package/docs/kit.md +55 -17
- package/docs/numerics.md +244 -121
- package/docs/reliability.md +15 -9
- package/docs/statistics.md +28 -4
- package/llms.txt +73 -41
- package/package.json +1 -1
- package/src/blockTridiagonal.js +85 -7
- package/src/checks.js +83 -6
- package/src/describe.js +46 -5
- package/src/device.js +33 -10
- package/src/index.js +23 -10
- package/src/particles.js +131 -55
- package/src/solution.js +164 -8
- package/src/solver.js +923 -277
- package/src/sources.js +10 -2
- package/src/stack.js +16 -6
- package/src/statistics.js +7 -3
- package/types/blockTridiagonal.d.ts +1 -0
- package/types/checks.d.ts +8 -2
- package/types/index.d.ts +2 -2
- package/types/particles.d.ts +3 -6
- package/types/solver.d.ts +50 -29
- package/types/stack.d.ts +6 -4
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` +
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
1304
|
-
const level = (i) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
92
|
-
//
|
|
93
|
-
const
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 =
|
|
393
|
+
d.x = place(d.species, d.cell);
|
|
320
394
|
}
|
|
321
395
|
return true;
|
|
322
396
|
}
|
|
323
397
|
|
|
324
|
-
//
|
|
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
|
-
|
|
328
|
-
|
|
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
|
}
|