driftlet 0.13.0 → 0.16.2

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.13.0",
3
+ "version": "0.16.2",
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": {
@@ -110,12 +110,17 @@ export class BlockTridiagonal {
110
110
  if (i === 0 || sizes[i - 1] === 0) {
111
111
  for (let k = 0; k < m * m; k++) lu[o + k] = B[o + k];
112
112
  } else {
113
+ // Row by row, skipping A's zeros (a gate or a local unknown has none to its neighbours):
114
+ // the same sums, subtracted in the same order.
113
115
  const mp = sizes[i - 1], oa = offA[i], op = offC[i - 1]; // C'_{i−1}: mp × m
114
116
  for (let r = 0; r < m; r++) {
115
- for (let c = 0; c < m; c++) {
116
- let s = B[o + r * m + c];
117
- for (let k = 0; k < mp; k++) s -= A[oa + r * mp + k] * cp[op + k * m + c];
118
- lu[o + r * m + c] = s;
117
+ const or = o + r * m;
118
+ for (let c = 0; c < m; c++) lu[or + c] = B[or + c];
119
+ for (let k = 0; k < mp; k++) {
120
+ const a = A[oa + r * mp + k];
121
+ if (a === 0) continue;
122
+ const ok = op + k * m;
123
+ for (let c = 0; c < m; c++) lu[or + c] -= a * cp[ok + c];
119
124
  }
120
125
  }
121
126
  }
@@ -130,8 +135,9 @@ export class BlockTridiagonal {
130
135
  if (i < n - 1) {
131
136
  const mn = sizes[i + 1], oc = offC[i];
132
137
  for (let c = 0; c < mn; c++) {
133
- for (let r = 0; r < m; r++) tmp[r] = C[oc + r * mn + c];
134
- luSolve(lu, o, piv, offX[i], m, tmp);
138
+ let any = false;
139
+ for (let r = 0; r < m; r++) if ((tmp[r] = C[oc + r * mn + c]) !== 0) any = true;
140
+ if (any) luSolve(lu, o, piv, offX[i], m, tmp); // (a zero column stays zero)
135
141
  for (let r = 0; r < m; r++) cp[oc + r * mn + c] = tmp[r];
136
142
  }
137
143
  }
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);
@@ -106,9 +110,12 @@ function balance(device, sol) {
106
110
  else ledgers[l].terms.push({ what, rate });
107
111
  };
108
112
  const last = regions.length - 1;
113
+ // (A contact that blocks a species is no source of it: what its flux reads there is the end
114
+ // box's own imbalance, which the ledger must show, not balance.)
115
+ const through = (side, i) => model.contacts[side].species[i].type !== 'blocked';
109
116
  species.forEach((sp, i) => {
110
- add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
111
- add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
117
+ if (through('left', i)) add(i, 0, 'left contact', sol.contacts.left.flux[sp.name]);
118
+ if (through('right', i)) add(i, last, 'right contact', -sol.contacts.right.flux[sp.name]);
112
119
  model.ports.forEach((port, k) => add(i, port.region, `port ${sol.ports[k].name}`, sol.ports[k].flux[sp.name]));
113
120
  });
114
121
  model.reactions.forEach((rx, k) => {
@@ -255,8 +262,12 @@ function gridCheck(device, sol, tol, gross) {
255
262
  const diffs = [];
256
263
  // The current, against the largest current any species carries (so at open circuit, or in
257
264
  // equilibrium, round-off isn't read as a change).
258
- const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross);
259
- if (gross > 0) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
265
+ // A current too small for the ledgers to call moving (a diode at low bias: 1e-4 A/m² against
266
+ // majority carriers that could carry 6e8) is compared still, above a floor at 1e-16 of what the
267
+ // species could carry, above what solves resolve (~1e-19 of it in a GaAs junction).
268
+ const natural = naturalScales(device, sol), floorI = 1e-16 * FARADAY * Math.max(0, ...model.species.map((sp, i) => Math.abs(sp.z) * natural[i]).filter(Number.isFinite));
269
+ const scaleI = Math.max(Math.abs(sol.current), Math.abs(other.current), gross, floorI);
270
+ if (gross > 0 || Math.max(Math.abs(sol.current), Math.abs(other.current)) > floorI) diffs.push({ what: 'current', a: sol.current, b: other.current, rel: Math.abs(other.current - sol.current) / scaleI, unit: unitsOf(model).current });
260
271
  // Floating terminals (driven by a current): their voltages, against the larger and the thermal voltage.
261
272
  const VT = model.RT / FARADAY;
262
273
  model.terminals.forEach((t, k) => {
package/src/describe.js CHANGED
@@ -49,6 +49,10 @@ export function unitWarnings(def) {
49
49
  if (c > 1e6) warn(path, `${num(c)} mol/m³ is over 1000 M; concentrations are mol/m³ (units.molar, and units.perCm3 for carriers)`);
50
50
  };
51
51
  (def.species ?? []).forEach((sp, i) => sp?.cRef !== undefined && conc(`species[${i}].cRef`, sp.cRef));
52
+ // SRH's n₁ is a concentration too (a value per cm³ typed in is 6e5 times too large)
53
+ (def.bulkReactions ?? []).forEach((rx, k) => {
54
+ for (const [m, law] of Object.entries(isObject(rx?.srh) ? rx.srh : {})) if (isObject(law) && law.n1 !== undefined) conc(`bulkReactions[${k}].srh.${m}.n1`, law.n1);
55
+ });
52
56
  for (const [mname, mat] of Object.entries(def.materials ?? {})) {
53
57
  if (!isObject(mat)) continue;
54
58
  const path = `materials.${mname}`;
@@ -92,13 +96,19 @@ export function unitWarnings(def) {
92
96
  };
93
97
  const kinetics = (path, reactions) => {
94
98
  (reactions ?? []).forEach((rx, k) => {
95
- if (rx?.k0 > 10) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
99
+ // (an exchange current only where electrons take part: an ion transfer's k0 can be large)
100
+ const said = rx?.equation ?? (rx?.left || rx?.right ? JSON.stringify([rx.left, rx.right]) : null);
101
+ const electrons = said === null || /(^|[^\w])e-($|[^\w])/.test(said);
102
+ if (rx?.k0 > 10 && electrons) warn(`${path}.reactions[${k}].k0`, `${num(rx.k0)} mol/(m²·s) is an exchange current of ${num(rx.k0 * FARADAY)} A/m² per electron; k0 is mol/(m²·s)`);
96
103
  });
97
104
  };
98
105
  (def.interfaces ?? []).forEach((f, k) => {
99
106
  if (!isObject(f)) return;
100
107
  capacitance(`interfaces[${k}].phi.C`, f.phi?.C);
101
- kinetics(`interfaces[${k}]`, f.reactions);
108
+ // (an exchange current is an electrode's: a metal on one side, or sides not yet known; a
109
+ // splitting exciton's k0, between two semiconductors, is a velocity times cRef)
110
+ const sides = [def.regions?.[k], def.regions?.[k + 1]].map((r) => (isObject(r) ? def.materials?.[r.material] : undefined));
111
+ if (sides.some((m) => m === undefined || isObject(m?.conductor))) kinetics(`interfaces[${k}]`, f.reactions);
102
112
  });
103
113
  (def.ports ?? []).forEach((p, k) => {
104
114
  if (!isObject(p)) return;
@@ -149,7 +159,12 @@ export function describe(def) {
149
159
  return;
150
160
  }
151
161
  const parts = [mat.epsr > 0 ? `εr ${mat.epsr}` : 'strictly neutral (ε = 0)'];
152
- if (reg.fixedCharge !== 0) parts.push(`fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(Math.abs(reg.fixedCharge) / FARADAY)}`);
162
+ // (donors and acceptors where there are electrons; an ion exchanger's charge in mol/m³)
163
+ const doped = species.some((sp, i) => mat.present[i] && (sp.name === 'e-' || sp.name === 'h+'));
164
+ if (reg.fixedCharge !== 0) {
165
+ const X = Math.abs(reg.fixedCharge) / FARADAY;
166
+ parts.push(doped ? `fixed charge ${reg.fixedCharge > 0 ? 'donor-like' : 'acceptor-like'} ${perCm3(X)}` : `fixed charge ${reg.fixedCharge > 0 ? '+' : '−'}${num(X)} mol/m³`);
167
+ }
153
168
  // The screening concentration: Σ z²c from the initial composition, plus the carriers that
154
169
  // balance the fixed charge (doping), which c0 doesn't list.
155
170
  let zzc = 0, q = reg.fixedCharge / FARADAY; // q: the charge c0 leaves unbalanced
@@ -216,8 +231,34 @@ export function describe(def) {
216
231
  const cdef = def.contacts?.[side];
217
232
  const ct = model.contacts[side];
218
233
  const linked = species.filter((sp, i) => ct.species[i].type !== 'blocked').map((sp) => sp.name);
219
- const term = ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
220
- lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}`);
234
+ // (a bath without a reference species: its V is its φ)
235
+ const term = ct.she && !ct.she.reference ? ' (terminal: the bath φ)' : ct.terminal === null ? '' : ` (terminal ${species[ct.terminal].name})`;
236
+ // A bath read through a reference species: where the SHE level sits against its terminal,
237
+ // so a held electrode's V reads on the familiar scale (a Cl⁻ reference on table μ° puts it
238
+ // 1.37 V below, a surprise to anyone expecting 0 V to mean SHE).
239
+ let she = '';
240
+ if (ct.she?.reference) {
241
+ const d = ct.she.shift, at = Math.abs(d) < 5e-4 ? 'V' : `V ${d < 0 ? '−' : '+'} ${Math.abs(d).toFixed(3)} V`;
242
+ she = ct.she.exact ? `; the bath's SHE level is at ${at}` : `; the bath's φ is at ${at} (on the usual tables' μ°, where μ°_H⁺ = 0, that's its SHE level)`;
243
+ }
244
+ lines.push(` ${side}: ${drive(cdef, model.geometry.type === 'planar')}${term}; ${linked.length ? `exchanges ${linked.join(', ')}` : 'exchanges nothing'}; φ ${ct.phi.type}${she}`);
245
+ // An outside phase out of equilibrium with a reaction that runs at the contact: the reaction
246
+ // then runs there for ever, a current at zero bias (H⁺ and OH⁻ given off Kw in a bath, say).
247
+ const end = regions[side === 'left' ? 0 : regions.length - 1].material;
248
+ model.reactions.forEach((rx, k) => {
249
+ if (!(rx.kf[end] > 0) || rx.kfProfile[end]) return; // (light, a profile, is meant to drive)
250
+ const parts = [...rx.reactants.map(({ i, nu }) => [i, -nu]), ...rx.products.map(({ i, nu }) => [i, nu])];
251
+ const level = (i) => {
252
+ const l = ct.species[i];
253
+ return l.type !== 'equilibrium' ? NaN : species[i].z === 0 ? l.mu : l.offset === undefined ? NaN : species[i].z * FARADAY * l.offset;
254
+ };
255
+ // A/RT from the outside's levels (the terminal voltage cancels: Σ ν z = 0)
256
+ const a = rx.fixedA - parts.reduce((sum, [i, nu]) => sum + nu * level(i), 0) / RT;
257
+ if (Math.abs(a) > 1e-3) {
258
+ const rdef = def.bulkReactions[k];
259
+ warnings.push(`contacts.${side}: the outside isn't in equilibrium with bulkReactions[${k}] (${rdef.equation ?? equation(rdef.nu)}; A = ${num(a)} RT there), so it runs at the contact for ever, a current even at zero bias. Unless that's meant (a source), give the outside a composition in equilibrium with it (H⁺ and OH⁻ at √K_w, say)`);
260
+ }
261
+ });
221
262
  }
222
263
  (def.ports ?? []).forEach((p, k) => {
223
264
  const port = model.ports[k];
package/src/device.js CHANGED
@@ -56,6 +56,49 @@ function positive(v, path) {
56
56
  return v;
57
57
  }
58
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
+
59
102
  // A profile against the device's x: { x, values }, piecewise linear between its points and
60
103
  // constant beyond its ends (read with profileAt).
61
104
  function profile(v, path, what, ok, okText) {
@@ -176,9 +219,12 @@ export function normalizeDevice(def) {
176
219
  const outside = (sp) => (def.ports ?? []).some((p) => p?.terminal === sp.name && Array.isArray(p.reactions) && p.reactions.length > 0);
177
220
  species.forEach((sp, i) => {
178
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);
179
224
  need(
180
225
  materials.some((m) => m.present[i]) || outside(sp),
181
- `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)" : ''),
182
228
  );
183
229
  });
184
230
 
@@ -326,6 +372,18 @@ export function normalizeDevice(def) {
326
372
  } catch (err) {
327
373
  throw new DeviceError(`grid: ${err.message}`);
328
374
  }
375
+ // A starting profile steeper than the cells under it starts as something else, silently: a
376
+ // liquid junction's potential then looks as if it grows while the junction forms.
377
+ regions.forEach((reg, r) => {
378
+ const missed = reg.c0Profile.map((p, i) => p && { i, ...unresolved(p, grid, r) }).filter((m) => m && m.off);
379
+ if (missed.length === 0) return;
380
+ const miss = missed.reduce((a, m) => (m.off > a.off ? m : a));
381
+ warnings.push(
382
+ `regions[${r}].c0 (${missed.map((m) => species[m.i].name).join(', ')}): the grid doesn't resolve the starting profile ` +
383
+ `(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), ` +
384
+ `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)`,
385
+ );
386
+ });
329
387
  const geometry = normalizeGeometry(def.geometry);
330
388
  if (geometry.type !== 'planar') {
331
389
  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)`));
@@ -1103,8 +1161,10 @@ function normalizeConductorInterface(idef, where, matL, matR, species, speciesIn
1103
1161
  return { phi, dipole: 0, zeroCharge, sheetCharge: 0, links, reactions, gates: [], conductor: { side, i: metal.i } };
1104
1162
  }
1105
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.
1106
1166
  function defaultInterfaceLinks(matL, matR, species) {
1107
- 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' }));
1108
1168
  }
1109
1169
 
1110
1170
  function transferCoefficient(v, path) {
@@ -1142,6 +1202,7 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1142
1202
  const links = species.map(() => ({ type: 'blocked' }));
1143
1203
  let phi = { type: 'neutral' };
1144
1204
  let terminal = null;
1205
+ let she = null; // a bath's SHE level against V
1145
1206
  if (cdef === undefined || cdef === null) return { drive: normalizeDrive({}, path), species: links, phi, terminal };
1146
1207
  need(isObject(cdef), `${path} must be an object`);
1147
1208
  fields(cdef, path, ['V', 'I', 'R', 'terminal', 'species', 'bath', 'phi', 'zeroCharge']);
@@ -1219,7 +1280,13 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1219
1280
  need(cdef.species === undefined, `${path}: give either bath or species links, not both`);
1220
1281
  const bath = cdef.bath;
1221
1282
  need(isObject(bath) && isObject(bath.c), `${path}.bath must be { c: { species: concentration }, reference }`);
1222
- 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(', ')})`);
1223
1290
  // Without a reference, V is the bath's φ (an ideal salt bridge: the membrane potential's
1224
1291
  // convention), and the terminal is the first charged species in it.
1225
1292
  const byPhi = bath.reference === undefined;
@@ -1233,24 +1300,35 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1233
1300
  need(bath.c[species[r].name] !== undefined, `${path}.bath.reference: '${bath.reference}' must be in the bath`);
1234
1301
  need(byPhi || cdef.terminal === undefined || terminal === r, `${path}.terminal must be the bath's reference species`);
1235
1302
  terminal = byPhi && terminal !== null ? terminal : r;
1236
- 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);
1237
1305
  const cb = new Float64Array(species.length);
1238
1306
  for (const [sname, v] of Object.entries(bath.c)) {
1239
1307
  need(speciesIndex.has(sname), `${path}.bath.c.${sname}: unknown species '${sname}'${known(speciesIndex)}`);
1240
1308
  const i = speciesIndex.get(sname);
1241
- 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}'`);
1242
1310
  cb[i] = positive(v, `${path}.bath.c.${sname}`);
1243
1311
  charge += species[i].z * cb[i];
1244
1312
  scale += Math.abs(species[i].z) * cb[i];
1245
1313
  }
1246
- 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
+ );
1247
1321
  // The reference species pins the bath's φ; every other species follows from composition.
1248
- const zb = bathZeta(mat, cb, region.background);
1249
- 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
1250
1324
  const refOffset = bath.offset === undefined ? 0 : finite(bath.offset, `${path}.bath.offset`);
1251
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 };
1252
1330
  for (let i = 0; i < species.length; i++) {
1253
- 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)
1254
1332
  links[i] =
1255
1333
  species[i].z === 0
1256
1334
  ? { type: 'equilibrium', mu: level(i) }
@@ -1288,5 +1366,5 @@ function normalizeContact(cdef, side, region, materials, species, speciesIndex,
1288
1366
  if (terminal !== null) {
1289
1367
  need(links[terminal].type !== 'blocked', `${path}.terminal: '${species[terminal].name}' is blocked at this contact`);
1290
1368
  }
1291
- return { drive, species: links, phi, terminal };
1369
+ return { drive, species: links, phi, terminal, ...(she ? { she } : {}) };
1292
1370
  }
package/src/index.js CHANGED
@@ -106,15 +106,27 @@ export class Device {
106
106
  this.def = def;
107
107
  this.model = model;
108
108
  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);
109
+ // The state carries over where the unknowns line up; on a new grid (the same regions, faces
110
+ // and terminals), interpolated onto it. (Started cold instead, a floating gate lost its
111
+ // charge to a refined grid.)
112
+ const sameUnknowns = old && old.u.length === solver.u.length && old.n === solver.n;
113
+ 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;
114
+ if (sameUnknowns || regridded) {
115
+ if (sameUnknowns) {
116
+ solver.u.set(old.u);
117
+ solver.uLo.set(old.uLo);
118
+ // A node newly on an electrode's surface (a window moved, a surface added) starts at its
119
+ // starting coverages, not at whatever its slots held.
120
+ const surfaceAt = (sv, g) => (sv.surfPort[g] < 0 ? '' : sv.model.ports[sv.surfPort[g]].surface.map((sp) => sp.name).join());
121
+ for (let g = 0; g < solver.nNodes; g++) if (solver.surfPort[g] >= 0 && surfaceAt(solver, g) !== surfaceAt(old, g)) solver._surfaceStart(g);
122
+ } else solver._warmFrom(old);
116
123
  solver.computeConcentrations();
117
124
  solver.time = old.time;
125
+ // Held sources read at the same time as before, and an adaptive transient goes on at the
126
+ // step size it had reached (its order restarts, as at any change of the equations).
127
+ solver.sourceTime = old.sourceTime;
128
+ solver._refreshSources();
129
+ solver.dtNext = old.dtNext;
118
130
  solver.contactDEnd = old.contactDEnd;
119
131
  if (old.portQEnd?.length === solver.portQ.length) solver.portQEnd = old.portQEnd;
120
132
  solver.solvedV = old.solvedV; // where the carried-over state was solved (for continuation)
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';