driftlet 0.16.2 → 1.0.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.
Files changed (51) hide show
  1. package/README.md +14 -3
  2. package/docs/device.md +29 -11
  3. package/docs/kit.md +10 -1
  4. package/docs/numerics.md +68 -12
  5. package/docs/statistics.md +2 -1
  6. package/docs/templates.md +260 -0
  7. package/llms.txt +107 -299
  8. package/package.json +15 -3
  9. package/src/blockTridiagonal.js +73 -1
  10. package/src/channels.js +6 -4
  11. package/src/checks.js +78 -3
  12. package/src/data.js +5 -1
  13. package/src/device.js +1 -1
  14. package/src/devices.js +2 -0
  15. package/src/equation.js +2 -1
  16. package/src/errors.js +33 -0
  17. package/src/grid.js +12 -0
  18. package/src/index.js +21 -10
  19. package/src/junction.js +3 -1
  20. package/src/kit.js +1 -1
  21. package/src/live.js +2 -0
  22. package/src/particles.js +2 -0
  23. package/src/plot.js +4 -0
  24. package/src/polarization.js +2 -1
  25. package/src/recorder.js +2 -1
  26. package/src/solution.js +39 -5
  27. package/src/solver.js +248 -30
  28. package/src/sources.js +8 -1
  29. package/src/stack.js +14 -1
  30. package/src/statistics.js +1 -1
  31. package/src/traces.js +3 -0
  32. package/src/types.js +3 -1
  33. package/types/blockTridiagonal.d.ts +1 -0
  34. package/types/channels.d.ts +3 -3
  35. package/types/checks.d.ts +9 -3
  36. package/types/data.d.ts +3 -3
  37. package/types/devices.d.ts +1 -1
  38. package/types/equation.d.ts +1 -1
  39. package/types/errors.d.ts +8 -0
  40. package/types/index.d.ts +4 -7
  41. package/types/junction.d.ts +2 -2
  42. package/types/kit.d.ts +1 -1
  43. package/types/live.d.ts +1 -1
  44. package/types/particles.d.ts +1 -1
  45. package/types/plot.d.ts +3 -3
  46. package/types/polarization.d.ts +1 -1
  47. package/types/recorder.d.ts +1 -1
  48. package/types/solver.d.ts +8 -1
  49. package/types/sources.d.ts +7 -7
  50. package/types/traces.d.ts +1 -1
  51. package/types/types.d.ts +9 -1
package/src/solution.js CHANGED
@@ -87,7 +87,7 @@ export function makeSolution(solver, result = {}) {
87
87
  }
88
88
  solver._assembleBookkeeping(solver.lastDt);
89
89
  sol.contacts = {};
90
- sol.gates = {};
90
+ sol.plates = {};
91
91
  for (const side of ['left', 'right']) {
92
92
  const ct = contacts[side];
93
93
  const flux = {};
@@ -107,7 +107,7 @@ export function makeSolution(solver, result = {}) {
107
107
  if (ct.phi.type === 'capacitive' || ct.phi.type === 'pinned') {
108
108
  // Charge on the gate (or metal) plate, per area if the device is planar: +D at the left, −D
109
109
  // at the right.
110
- sol.gates[side] = { V, D, charge: side === 'left' ? D : -D };
110
+ sol.plates[side] = { V, D, charge: side === 'left' ? D : -D };
111
111
  }
112
112
  }
113
113
  sol.current = sol.contacts.right.current;
@@ -152,6 +152,16 @@ export function makeSolution(solver, result = {}) {
152
152
  }
153
153
  return out;
154
154
  });
155
+ // A floating terminal passes what its circuit says, exactly (see passedCurrent).
156
+ if (sol.converged) {
157
+ for (const k of solver.floating) {
158
+ const I = solver.passedCurrent(k);
159
+ if (k === 0) sol.contacts.left.current = I;
160
+ else if (k === 1) sol.contacts.right.current = -I;
161
+ else sol.ports[k - 2].current = I;
162
+ }
163
+ }
164
+ sol.current = sol.contacts.right.current;
155
165
  sol.terminalVoltage = sol.contacts.right.V - sol.contacts.left.V;
156
166
  // Every terminal (the contacts, then the ports by name): its voltage and its current into the
157
167
  // device, conduction plus displacement. They sum to zero in steady state.
@@ -303,11 +313,24 @@ export function makeSolution(solver, result = {}) {
303
313
  // refined. Flag it where it lowers that capacitance by over 2%. (Where the box is the smaller
304
314
  // part, as an ε = 0 metal's against a semiconductor's depletion, it's a fine stand-in for the
305
315
  // metal's own screening.)
306
- const boxed = (g, h, phi, other, where) => {
316
+ // And a reaction at the face sees that box's composition, which holding the charge σ shifts
317
+ // by about z·σ/(Cbox·V_T) for an ion of charge z: a Frumkin effect set by the mesh, which grows
318
+ // without limit as the box shrinks. Flag it past 1%.
319
+ const boxed = (g, h, phi, other, where, sigma = 0, reacting = []) => {
307
320
  const mat = model.materials[model.regions[grid.nodeRegion[g]].material];
308
321
  if (mat.conductor || mat.epsr !== 0 || solver.phiUndefined?.[g]) return;
309
322
  const s2 = solver.screening(g), Cbox = (F * F * s2 * h) / (2 * RT);
310
323
  if (!(s2 > 0)) return;
324
+ const ion = reacting.filter((i) => species[i].z !== 0).reduce((a, i) => (a < 0 || Math.abs(species[i].z) > Math.abs(species[a].z) ? i : a), -1);
325
+ const shift = ion < 0 ? 0 : (Math.abs(species[ion].z * sigma) / Cbox) * (F / RT);
326
+ if (shift > 0.01) {
327
+ sol.warnings.push(
328
+ `${where}: the reaction here sees the strictly neutral cell beside the face (${h.toExponential(2)} m), which holds the face's charge as a diffuse layer that wide: ` +
329
+ `${species[ion].name} there is shifted by about ${(100 * shift).toPrecision(2)}%, a Frumkin effect set by the mesh that grows as the cell shrinks. ` +
330
+ `Set the face's zeroCharge near its rest potential (so it holds little charge), coarsen that cell to ${((200 * Math.abs(species[ion].z * sigma)) / (F * s2)).toExponential(1)} m or more, ` +
331
+ 'or resolve the double layer (epsr > 0, hmin below the Debye length).',
332
+ );
333
+ }
311
334
  let C = phi.C;
312
335
  if (phi.type === 'pinned') {
313
336
  const om = other < 0 ? null : model.materials[model.regions[grid.nodeRegion[other]].material];
@@ -328,8 +351,10 @@ export function makeSolution(solver, result = {}) {
328
351
  const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
329
352
  check(gL, grid.segLength[gL - 1], `interfaces[${f}] (left side)`);
330
353
  check(gR, grid.segLength[gR], `interfaces[${f}] (right side)`);
331
- boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`);
332
- boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`);
354
+ const reacting = (side) => itf.reactions.flatMap((rx) => rx.part.filter((p) => p.side === side).map((p) => p.i));
355
+ const sigma = sol.interfaces[f].D;
356
+ boxed(gL, grid.segLength[gL - 1], itf.phi, gR, `interfaces[${f}] (left side)`, sigma, reacting(0));
357
+ boxed(gR, grid.segLength[gR], itf.phi, gL, `interfaces[${f}] (right side)`, sigma, reacting(1));
333
358
  });
334
359
  const resolves = (ct) => ct.phi.type === 'capacitive' || ct.phi.type === 'pinned';
335
360
  if (resolves(contacts.left)) {
@@ -361,6 +386,15 @@ export function makeSolution(solver, result = {}) {
361
386
  }
362
387
  });
363
388
  });
389
+ // A reaction a steady state needs at equilibrium, left out of it (see stuckReactions).
390
+ if (sol.steady) {
391
+ for (const { where, affinity } of solver.stuckReactions()) {
392
+ sol.warnings.push(
393
+ `${where}: out of equilibrium by ${(affinity * solver.VT * 1e3).toPrecision(3)} mV of affinity wherever it acts, yet nothing else makes or uses what it changes, so a steady state needs it at equilibrium. ` +
394
+ 'Its rate is too small for the solve to resolve, and it was left where the solve began: this is not a steady state. Raise its rate constant (the steady state does not depend on it), or advance() in time to see how slowly it gets there.',
395
+ );
396
+ }
397
+ }
364
398
  // Any other species denser than any material can be (1e6 mol/m³: water is 5.5e4, a metal's
365
399
  // atoms ~1.5e5): a level set where the device can't follow it, such as a contact's for one ion
366
400
  // far from its neighbours' (Na⁺ held at 1.2e37 mol/m³, whose currents then read 1e28 A/m²).
package/src/solver.js CHANGED
@@ -491,7 +491,7 @@ export class Solver {
491
491
  this.steady = false; // inside a steady solve: sources at the present time, not a step's end
492
492
  // The floating ones are extra unknowns, solved with the grid's by bordering (_solveBordered).
493
493
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
494
- this.drivenKey = this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}`).join();
494
+ this.drivenKey = this._drivenKey();
495
495
  this.lawRefs = new Map(); // what a steady solve keeps behind a terminal at no current (_captureLaws)
496
496
  this.chargeRefs = new Map(); // …and the charge a terminal at no current that only charges keeps
497
497
  // Per terminal: B = ∂res/∂V (compact column), C = ∂I/∂x (compact row), ∂I/∂V, and for a
@@ -882,6 +882,73 @@ export class Solver {
882
882
  // first, so that the first takes that row, and the rest have its multiple taken off.
883
883
  const through = (w) => this.carriers.some((k, j) => w[X + j] !== 0);
884
884
  const held = new Map(); // (terminal → the combination in place of its circuit row)
885
+ // Reactions' equilibria first. A face reaction that alone changes some combination (without
886
+ // it, w would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
887
+ // Ag⁺ + AgNO₃ where it also complexes) has that combination's summed balance rows equal to its
888
+ // rate, w·ν times it, which a steady state sets to zero. The rate is a poor row: k₀ times a
889
+ // function of the levels, beside diffusion's terms in the same balances. An Ag⁺/Ag couple
890
+ // 1e-20 as fast as Fe³⁺/Fe²⁺ on the same platinum was lost to their round-off, the
891
+ // open-circuit potential settling where silver wasn't (−0.4661 V, the joint equilibrium
892
+ // −0.5004 V), and 1e-15 to 1e-18 failed. Its affinity says the same at any k₀ (each kinetic
893
+ // form vanishes only there), so it stands in for one of those rows (see _applyAffinities),
894
+ // chosen by the same elimination, and a stretch's: never a circuit row.
895
+ // The slowest first, whose rate rows are the poorest, and each row picked by the reaction's
896
+ // own terms, never by where its species are listed: the same rows in any listing. (Picked by
897
+ // listing, an electrode's two couples took different rows with the species reversed, and
898
+ // Newton converged in one listing and crawled in the other.)
899
+ this.affinities = [];
900
+ const faceRx = model.interfaces.flatMap((itf, f) => itf.reactions.map((rx) => ({ f, rx, key: rx.part.map((p) => `${model.species[p.i].name}:${p.side}:${p.nu}`).sort().join() })));
901
+ faceRx.sort((a, b) => a.rx.k0 - b.rx.k0 || (a.key < b.key ? -1 : a.key > b.key ? 1 : a.f - b.f));
902
+ for (const { f, rx } of faceRx) {
903
+ const nu = new Float64Array(S);
904
+ let inFlat = false;
905
+ for (const p of rx.part) {
906
+ const k = this.stretchOf[(f + p.side) * this.n + p.i];
907
+ if (k < 0 || this.flatStretches.includes(this.stretches[k])) inFlat = true;
908
+ else nu[k] += p.nu;
909
+ }
910
+ if (inFlat) continue; // (held flat at a contact's levels, in equilibrium already)
911
+ // (Not one fed by a terminal driven by a current: its sum is the rate plus what that
912
+ // passes. Silver's, e⁻ + Fe²⁺ with the electrons fed, or Ag⁺ + AgNO₃, which nothing is.
913
+ // A current held at zero passes nothing: an electrode at open circuit feeding a closed
914
+ // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
915
+ const passes = (k) => !this._noCurrent(this.terms[k]);
916
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && driven(this.stretches[k]).some(passes))));
917
+ // Any of the combinations it alone changes will do, with any conserved amount added (whose
918
+ // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
919
+ // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
920
+ const news = this._conservedMoieties(true, [], rx).filter((w) => Math.abs(nu.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9 && !fed(w));
921
+ const kept = laws.filter((l) => !fed(l));
922
+ const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
923
+ for (const w of tries) {
924
+ const v = new Float64Array(S + T);
925
+ for (let k = 0; k < S; k++) {
926
+ if (w[k] === 0) continue;
927
+ const st = this.stretches[k];
928
+ if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = w[k];
929
+ if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = w[k] / this.z[st.species];
930
+ }
931
+ for (const [col, row] of chosen) {
932
+ const fc = v[col];
933
+ if (fc !== 0) for (let j = 0; j < S + T; j++) v[j] -= fc * row[j];
934
+ }
935
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
936
+ if (!(scale > 1e-9)) continue;
937
+ // (Never a metal's carriers, nor a stretch a terminal feeds, even at no current: their rows
938
+ // carry the terminal's current, and Pt's electrons standing for an iron couple's balance
939
+ // put the electrode at 602 V.)
940
+ const ok = (j) => j !== undefined && j >= 0 && j < S && Math.abs(v[j]) > 1e-9 * scale && !conductor(this.stretches[j]) && this.terms.every((_, t) => !this._feeds(t, this.stretches[j]));
941
+ // (One of its own species first, then weight 1, the largest, and by name.)
942
+ const own = (k) => rx.part.some((p) => p.i === this.stretches[k].species && this.stretchOf[(f + p.side) * this.n + p.i] === k);
943
+ const key = (k) => [own(k) ? 0 : 1, Math.abs(v[k]) === 1 ? 0 : 1, -Math.abs(v[k]), model.species[this.stretches[k].species].name, this.stretches[k].regions[0]];
944
+ const less = (a, b) => { const ka = key(a), kb = key(b); for (let j = 0; j < ka.length; j++) if (ka[j] !== kb[j]) return ka[j] < kb[j]; return false; };
945
+ const col = [...Array(S).keys()].filter(ok).reduce((b, k) => (b < 0 || less(k, b) ? k : b), -1);
946
+ if (col < 0) continue;
947
+ chosen.push([col, v.map((x) => x / v[col])]);
948
+ this.affinities.push({ rx, f, row: rowOf(col), idx: new Int32Array(rx.part.length), w: new Float64Array(rx.part.length), len: 0, res: 0 });
949
+ break;
950
+ }
951
+ }
885
952
  for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
886
953
  for (const [j, k] of this.carriers.entries()) {
887
954
  const c = held.get(k);
@@ -947,7 +1014,7 @@ export class Solver {
947
1014
  this.pinnedIslands = [];
948
1015
  this.islandsOn = false; // (newton() turns them on, see there)
949
1016
  this.pins = [];
950
- const used = new Set(this.constraints.map((cs) => cs.row).filter((o) => o >= 0));
1017
+ const used = new Set([...this.constraints.map((cs) => cs.row).filter((o) => o >= 0), ...this.affinities.map((af) => af.row)]);
951
1018
  for (const st of this.stretches) {
952
1019
  const i = st.species, [s0, s1] = st.regions;
953
1020
  if (!st.mobile || conductor(st)) continue;
@@ -1160,7 +1227,7 @@ export class Solver {
1160
1227
  // charges, so a combination weighted by z there is conserved while it passes none: a battery's
1161
1228
  // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
1162
1229
  // elimination will do.
1163
- _conservedMoieties(drives = false, alsoCurrent = []) {
1230
+ _conservedMoieties(drives = false, alsoCurrent = [], skip = null) {
1164
1231
  const { model, n, z } = this, S = this.stretches.length;
1165
1232
  // With `drives`, an electrode port driven by a current whose reactions take its carrier from
1166
1233
  // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
@@ -1169,12 +1236,14 @@ export class Solver {
1169
1236
  const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
1170
1237
  this.carriers = drives ? this.terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && model.ports[k - 2].reactions.length > 0 ? [k] : [])) : [];
1171
1238
  const X = S + this.surfCols.length, C = X + this.carriers.length;
1172
- const rows = [];
1239
+ // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1240
+ // its own rows, over the columns, are handed back as `basis.skipped`.)
1241
+ const rows = [], skipped = [];
1173
1242
  model.interfaces.forEach((itf, f) => {
1174
1243
  for (const rx of itf.reactions) {
1175
1244
  const row = new Float64Array(C);
1176
1245
  for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
1177
- rows.push(row);
1246
+ (rx === skip ? skipped : rows).push(row);
1178
1247
  }
1179
1248
  });
1180
1249
  // An electrode surface's species are columns too, after the stretches (S + j for surface
@@ -1183,12 +1252,13 @@ export class Solver {
1183
1252
  model.ports.forEach((port, k) => {
1184
1253
  for (const rx of port.reactions) {
1185
1254
  const row = new Float64Array(C); // (the electrode's carrier is outside the device)
1255
+ if (rx === skip) skipped.push(row);
1186
1256
  for (const p of rx.part) {
1187
1257
  if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
1188
1258
  else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
1189
1259
  else if (this.carriers.includes(k + 2)) row[X + this.carriers.indexOf(k + 2)] += p.nu;
1190
1260
  }
1191
- rows.push(row);
1261
+ if (rx !== skip) rows.push(row);
1192
1262
  }
1193
1263
  });
1194
1264
  model.regions.forEach((reg, q) => {
@@ -1197,7 +1267,7 @@ export class Solver {
1197
1267
  const row = new Float64Array(C);
1198
1268
  for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
1199
1269
  for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
1200
- rows.push(row);
1270
+ (rx === skip ? skipped : rows).push(row);
1201
1271
  }
1202
1272
  });
1203
1273
  const unit = (k) => {
@@ -1254,6 +1324,7 @@ export class Solver {
1254
1324
  w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
1255
1325
  basis.push(w);
1256
1326
  }
1327
+ basis.skipped = skipped;
1257
1328
  return basis;
1258
1329
  }
1259
1330
 
@@ -1508,12 +1579,67 @@ export class Solver {
1508
1579
  if (this.floating.length !== before.length) this.deltaV = null;
1509
1580
  // A terminal newly driven by a current (or no longer) can close a combination behind it.
1510
1581
  // (and one put behind a resistance, or taken from it, its time steps' reading: see _laws)
1511
- this.drivenKey = this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}`).join();
1582
+ this.drivenKey = this._drivenKey();
1512
1583
  if (this.drivenKey !== driven) this._laws();
1513
1584
  this._refreshSources();
1514
1585
  this.history = [];
1515
1586
  }
1516
1587
 
1588
+ // After a steady solve: each reaction that alone changes some combination nothing else feeds
1589
+ // (see _laws), whose total rate a steady state therefore sets to zero, but whose affinity has one
1590
+ // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
1591
+ // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
1592
+ // port's slow couple at its starting composition, each converged without a word. (A face
1593
+ // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
1594
+ stuckReactions() {
1595
+ const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
1596
+ const passes = (k) => !this._noCurrent(this.terms[k]);
1597
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && this.terms.some((t, j) => t.drive.kind === 'I' && passes(j) && this._feeds(j, this.stretches[k])))));
1598
+ const needed = (rx) => {
1599
+ const basis = this._conservedMoieties(true, [], rx);
1600
+ return basis.some((w) => !fed(w) && basis.skipped.some((row) => Math.abs(row.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9));
1601
+ };
1602
+ const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
1603
+ const report = (where, as) => {
1604
+ if (as.length === 0 || !(as.every((a) => a > 1e-6) || as.every((a) => a < -1e-6))) return;
1605
+ out.push({ where, affinity: as.reduce((m, a) => (Math.abs(a) < Math.abs(m) ? a : m)) });
1606
+ };
1607
+ model.interfaces.forEach((itf, f) => {
1608
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
1609
+ itf.reactions.forEach((rx, q) => {
1610
+ if (this.affinities.some((af) => af.rx === rx) || !needed(rx)) return;
1611
+ report(`interfaces[${f}].reactions[${q}]`, [rx.part.reduce((a, p) => a - p.nu * eta(p.side ? gR : gL, 1 + p.i), rx.fixedA)]);
1612
+ });
1613
+ });
1614
+ model.reactions.forEach((mrx, x) => {
1615
+ const rx = this.rxs[x];
1616
+ if (rx.generation || !needed(mrx)) return;
1617
+ const as = [];
1618
+ for (let g = 0; g < this.nNodes; g++) if (rx.kfNode[g] > 0 || rx.srh?.[this.nodeMaterial[g]]) as.push(rx.sp.reduce((a, i, p) => a + rx.nu[p] * eta(g, 1 + i), rx.fixedA));
1619
+ report(`bulkReactions[${x}]`, as);
1620
+ });
1621
+ model.ports.forEach((port, k) => {
1622
+ port.reactions.forEach((rx, q) => {
1623
+ if (!needed(rx)) return;
1624
+ let aV = rx.fixedA;
1625
+ for (const p of rx.part) if (p.side === 1) aV -= p.nu * this.portEta(port, p.i);
1626
+ report(`ports[${k}].reactions[${q}]`, port.nodes.map((g) => rx.part.reduce((a, p) => (p.side === 1 ? a : a - p.nu * eta(g, p.side === 2 ? 1 + n + p.s : 1 + p.i)), aV)));
1627
+ });
1628
+ });
1629
+ return out;
1630
+ }
1631
+
1632
+ // How each terminal is driven, as far as the laws care (see _laws): by a voltage or a current,
1633
+ // behind a resistance or not, and a current that's held at zero throughout (which feeds a
1634
+ // combination nothing, so a reaction's affinity may stand in for its summed balance).
1635
+ _drivenKey() {
1636
+ return this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}${this._noCurrent(t) ? '0' : ''}`).join();
1637
+ }
1638
+
1639
+ _noCurrent(t) {
1640
+ return t.drive.kind === 'I' && !(t.drive.R > 0) && t.drive.src.value === 0;
1641
+ }
1642
+
1517
1643
  // Held terminals take their sources' values at sourceTime (a floating one keeps its voltage,
1518
1644
  // an unknown; one behind a resistance reads its source voltage in _circuit).
1519
1645
  _refreshSources() {
@@ -1779,6 +1905,22 @@ export class Solver {
1779
1905
  });
1780
1906
  if (w > 0) this.termV[k] = port.capacitance.zeroCharge + sum / w;
1781
1907
  }
1908
+ // A floating port linked to its carrier (no reactions, no double layer: a wire into an
1909
+ // insertion host) starts at that carrier's level across its window, not at 0 V.
1910
+ for (const k of this.floating) {
1911
+ const t = this.terms[k], port = t.kind === 'port' ? model.ports[t.index] : null;
1912
+ const i = port?.terminal, link = i === null || i === undefined ? null : port.species[i];
1913
+ if (!port || port.reactions.length > 0 || port.capacitance || !link || link.type === 'blocked' || z[i] === 0) continue;
1914
+ let w = 0, sum = 0;
1915
+ port.nodes.forEach((g, j) => {
1916
+ const a = this.model.grid.vol[g] * (this.portArea[t.index]?.[j] ?? 1); // (a window with no area: by volume)
1917
+ w += a;
1918
+ sum += (a * this.VT * u[this.blockOfNode[g] * M + 1 + i]) / z[i];
1919
+ });
1920
+ // (Levels at the start mean something only against a held terminal's, as for a contact.)
1921
+ const held = [0, 1].find((h) => !this.floating.includes(h) && Number.isFinite(level(this.terms[h].side)));
1922
+ if (w > 0 && held !== undefined) this.termV[k] = this.termV[held] + sum / w - (link.offset ?? 0) - level(this.terms[held].side);
1923
+ }
1782
1924
  // Gates start open as far as the starting voltage across their face holds them, α/(α + β).
1783
1925
  for (const gt of this.gateList) {
1784
1926
  const { gate, o } = gt, V = this._gateVoltage(gt), a = gateRate(gate.alpha, V)[0], b = gateRate(gate.beta, V)[0];
@@ -2031,10 +2173,11 @@ export class Solver {
2031
2173
  // balances, the last two kept aside for _solveBordered.
2032
2174
  const steady = this.steady && dt === Infinity;
2033
2175
  if (steady) this._flatRows();
2176
+ if (steady && this.affinities.length > 0) this._applyAffinities();
2034
2177
  if (steady && this.constraints.length > 0) this._applyConstraints();
2035
2178
  if (steady && this.localConstraints.length > 0) this._applyLocalConstraints();
2036
2179
  this.pinnedIslands = steady && this.islandsOn ? this._applyIslands() : [];
2037
- this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.pinnedIslands] : [];
2180
+ this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.affinities, ...this.pinnedIslands] : [];
2038
2181
  this.transformed = this.combining && dt !== Infinity;
2039
2182
  if (this.transformed) this._chargeRows(dt);
2040
2183
  }
@@ -3274,6 +3417,31 @@ export class Solver {
3274
3417
  }
3275
3418
  }
3276
3419
 
3420
+ // Steady solves: each reaction that alone changes a combination (see _laws) held at its
3421
+ // equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
3422
+ // like an amount's).
3423
+ _applyAffinities() {
3424
+ const { M, u, uLo, res } = this, grid = this.model.grid;
3425
+ for (const af of this.affinities) {
3426
+ const b0 = Math.floor(af.row / M), r0 = af.row % M;
3427
+ this._replaceRow(b0, r0);
3428
+ this._j(b0, r0, b0, r0, 1);
3429
+ res[this.rix[af.row]] = 0;
3430
+ const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
3431
+ let hi = af.rx.fixedA, lo = 0, len = 0;
3432
+ for (const p of af.rx.part) {
3433
+ const o = this.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
3434
+ hi -= p.nu * u[o];
3435
+ lo -= p.nu * uLo[o];
3436
+ if (this.loc[o] < 0) continue;
3437
+ af.idx[len] = this.rix[o];
3438
+ af.w[len++] = -p.nu;
3439
+ }
3440
+ af.len = len;
3441
+ af.res = hi + lo;
3442
+ }
3443
+ }
3444
+
3277
3445
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
3278
3446
  _replaceRow(b, rs) {
3279
3447
  const l = this.loc[b * this.M + rs];
@@ -3411,8 +3579,13 @@ export class Solver {
3411
3579
  // stops refining.
3412
3580
  const x0 = Float64Array.from(x);
3413
3581
  const ok = gmresReal(op, pb, x, { m: 8, restarts: 1, tol: 1e-8 });
3414
- let c = 0;
3415
- for (let j = 0; j < N + K; j++) c = Math.max(c, Math.abs(x[j] - x0[j]));
3582
+ // (Measured on the potentials, as Newton's own step is: a rate the plain solve left
3583
+ // unsolved, at a perturbed pivot, can need a correction of 41.7 mol/(m²·s).)
3584
+ const dx = new Float64Array(N + 1), dV = new Float64Array(K);
3585
+ for (let j = 0; j < N; j++) dx[j] = x[j] - x0[j];
3586
+ for (let a = 0; a < K; a++) dV[a] = x[N + a] - x0[N + a];
3587
+ if (this.transformed) this._untransform(dx);
3588
+ const c = this._maxPotentialStep(dx, dV);
3416
3589
  if (!ok || !(c < 1)) return false;
3417
3590
  for (let j = 0; j < N; j++) delta[j] = x[j];
3418
3591
  for (let a = 0; a < K; a++) deltaV[a] = x[N + a];
@@ -3464,7 +3637,19 @@ export class Solver {
3464
3637
  // A/m² of noise), their rows can throw Newton off instead: if it doesn't converge again
3465
3638
  // within a dozen iterations, the plain solution stands.
3466
3639
  this.islandsOn = islands;
3467
- let rounds = 0, kept = null;
3640
+ let rounds = 0, kept = null, perturbed = false;
3641
+ // Converged by a small update, but solved at a perturbed pivot, or in a steady solve (where a
3642
+ // factorisation that lost a row's digits left a reaction's rate 3.6e7 off it, the update
3643
+ // small, an I = 0 electrode at a voltage 0.4 V from its steady one): the residual itself,
3644
+ // assembled at the state, must be small too (each row scaled by its largest Jacobian entry).
3645
+ const unsolved = () => {
3646
+ if (!perturbed && dt !== Infinity) return false;
3647
+ this.assemble(dt);
3648
+ this._equilibrate();
3649
+ let r = 0;
3650
+ for (let k = 0; k < this.SINK; k++) r = Math.max(r, Math.abs(this.res[k]));
3651
+ return !(r < 1e-6);
3652
+ };
3468
3653
  const islandsOff = (it, result) => {
3469
3654
  if (dt !== Infinity || !this.steady || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3470
3655
  this.islandsOn = true;
@@ -3509,6 +3694,13 @@ export class Solver {
3509
3694
  } catch (err) {
3510
3695
  return fail({ converged: false, iterations: it, history, error: err.message });
3511
3696
  }
3697
+ // A pivot perturbed (one that cancelled to exactly nothing) can leave a row unsolved, its
3698
+ // update small while its residual isn't: the same couple written twice on an electrode at
3699
+ // open circuit converged with one spelling's rate row 41.7 mol/(m²·s) off, the couple
3700
+ // 1.5 RT from equilibrium. Refine the solves (exact J·v) wherever one was, and before
3701
+ // taking a small update for convergence there, look at the residual itself (see unsolved).
3702
+ perturbed = this.sys.perturbedPivots > 0;
3703
+ if (perturbed && !noRefine) refine = true;
3512
3704
  try {
3513
3705
  const pins = this.pins;
3514
3706
  deltaV.fill(0);
@@ -3547,6 +3739,7 @@ export class Solver {
3547
3739
  const last = history[history.length - 2], theta = step / last;
3548
3740
  const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
3549
3741
  if (alpha === 1 && (step < tol || contracted)) {
3742
+ if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3550
3743
  const done = { converged: true, iterations: it, history, residual: rmax };
3551
3744
  if (islandsOff(it, done)) continue;
3552
3745
  this.computeConcentrations();
@@ -3567,6 +3760,7 @@ export class Solver {
3567
3760
  if (refine) refined++;
3568
3761
  else if (!noRefine && alpha === 1 && it >= 3 && step > 0.25 * p1 && (step < 1e-4 || (step > p1 && p1 > p2))) refine = true;
3569
3762
  if (alpha === 1 && it >= 4 && (!refine || refined >= 1 || noRefine) && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3763
+ if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3570
3764
  const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3571
3765
  if (islandsOff(it, done)) continue;
3572
3766
  this.computeConcentrations();
@@ -3676,11 +3870,7 @@ export class Solver {
3676
3870
  this.portQEnd = Float64Array.from(this.portQ);
3677
3871
  }
3678
3872
  const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
3679
- if (opts.guess) {
3680
- // Start Newton from a predicted state (e.g. extrapolated from the history).
3681
- this.u.set(opts.guess);
3682
- this.uLo.fill(0);
3683
- }
3873
+ if (opts.guess) this._guess(opts.guess); // start Newton from a predicted state
3684
3874
  let dtEff = dt;
3685
3875
  if (bdf) {
3686
3876
  const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
@@ -3717,10 +3907,7 @@ export class Solver {
3717
3907
  this.u.set(this.uPrev);
3718
3908
  this.uLo.set(this.uPrevLo);
3719
3909
  this.termV.set(this.termVPrev);
3720
- if (opts.guess) {
3721
- this.u.set(opts.guess);
3722
- this.uLo.fill(0);
3723
- }
3910
+ if (opts.guess) this._guess(opts.guess);
3724
3911
  const again = this.newton(dtEff, { ...opts, islands: true });
3725
3912
  result = { ...again, iterations: result.iterations + again.iterations };
3726
3913
  }
@@ -3730,10 +3917,7 @@ export class Solver {
3730
3917
  this.u.set(this.uPrev);
3731
3918
  this.uLo.set(this.uPrevLo);
3732
3919
  this.termV.set(this.termVPrev);
3733
- if (opts.guess) {
3734
- this.u.set(opts.guess);
3735
- this.uLo.fill(0);
3736
- }
3920
+ if (opts.guess) this._guess(opts.guess);
3737
3921
  for (const cs of this.lawForm.keys()) this.lawForm.set(cs, true);
3738
3922
  const again = this.newton(dtEff, opts);
3739
3923
  result = { ...again, iterations: result.iterations + again.iterations };
@@ -3766,6 +3950,19 @@ export class Solver {
3766
3950
  return result;
3767
3951
  }
3768
3952
 
3953
+ // Newton's unknowns set to a guess (e.g. extrapolated from the history). Only its unknowns: the
3954
+ // other slots never change (see _activeSlots), and among them is a gauge, an insertion host's
3955
+ // φ̂, that nothing pulls back. Extrapolated through steps that grow, it wandered off
3956
+ // geometrically, 147 to 8000 thermal units in a battery's first 100 s and 1e11 by 1e5 s, where
3957
+ // its species' levels (η − zφ̂, summed back by the host's statistics) lost their digits to it.
3958
+ _guess(guess) {
3959
+ const { u, uLo } = this;
3960
+ for (const k of this.fullOf) {
3961
+ u[k] = guess[k];
3962
+ uLo[k] = 0;
3963
+ }
3964
+ }
3965
+
3769
3966
  /**
3770
3967
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
3771
3968
  * estimated against an explicit predictor through the previous states, and controlled to
@@ -3786,8 +3983,13 @@ export class Solver {
3786
3983
  const trace = { t: [], current: [], voltage: [], ...(sample ? { probes: sample.out } : {}) };
3787
3984
  let steps = 0, rejected = 0, iterations = 0, failed = false;
3788
3985
  // Already there (an animation frame with no time to add): nothing to do, and the step size
3789
- // carried to the next call stays as it was.
3790
- if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
3986
+ // carried to the next call stays as it was. A time already passed, or reached again outside
3987
+ // an animation, is most likely advance(dt) read as a duration: say so.
3988
+ if (!(tEnd > this.time)) {
3989
+ const relative = tEnd < this.time || (this.time > 0 && opts.budgetMs === undefined);
3990
+ return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace, ...(relative ? { stopped: `advance(${tEnd}) did nothing: ${absolute}. To run for a further dt, advance(sol.time + dt)` } : {}) };
3991
+ }
3992
+ const t0 = this.time;
3791
3993
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
3792
3994
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
3793
3995
  // The shortest step worth trying: round-off relative to the time, or the device's fastest
@@ -3800,8 +4002,10 @@ export class Solver {
3800
4002
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
3801
4003
  // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
3802
4004
  // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
3803
- // strictly neutral device, fails outright.
3804
- if (tEnd - this.time <= 1e-10 * Math.abs(tEnd)) {
4005
+ // strictly neutral device, fails outright. Round-off of the time and negligible beside the
4006
+ // interval asked for: by the time alone, a GITT pulse of 0.01 s at t = 1e9 s was skipped
4007
+ // whole (the time moved, nothing else did), and from 1e10 s 17% of a pulse train's charge.
4008
+ if (tEnd - this.time <= 1e-10 * Math.abs(tEnd) && tEnd - this.time <= 1e-9 * (tEnd - t0)) {
3805
4009
  this.time = tEnd;
3806
4010
  break;
3807
4011
  }
@@ -3895,7 +4099,10 @@ export class Solver {
3895
4099
  // Stopped short, other than on the caller's wall-time budget: say where, and why.
3896
4100
  let stopped;
3897
4101
  const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3898
- if (failed) stopped = `${at}: a step didn't converge even when shortened to ${dt.toExponential(1)} s. A drive's jump too big for one step (ramp it), a driven current the device can't keep passing (a diode's reverse recovery past its storage time: stop there), or a device the steady solves also find hard.`;
4102
+ // (What usually does it, most likely first. Round 11's agents read the old list, which led
4103
+ // with a drive's jump, as blaming a modest current pulse: a host emptied at its surface, and
4104
+ // an electrode at open circuit with no double layer, held only by a slow reaction.)
4105
+ if (failed) stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. Look for: a host or surface driven to its limit (emptied or filled at its surface by a current it can no longer pass); an electrode at open circuit held only by a slow reaction, with no double-layer capacitance to keep its level (give its face one: phi capacitive); a drive's jump too big for one step (ramp it); a driven current the device cannot keep passing (a diode's reverse recovery past its storage time: stop there); or a device the steady solves also find hard.`;
3899
4106
  else if (this.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
3900
4107
  return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3901
4108
  }
@@ -4113,8 +4320,19 @@ export class Solver {
4113
4320
  this.computeConcentrations();
4114
4321
  }
4115
4322
 
4323
+ // What a floating terminal k passes into the device, which its circuit row holds exactly once
4324
+ // converged: the current it's driven by, or (V_source − V)/R behind a resistance (the source
4325
+ // as the last step saw it, before any jump at its end). Read as a flux through a metal instead,
4326
+ // it carried that flux's round-off, G Δη with G huge: a gate behind R read 1.5e-4 off wherever
4327
+ // Newton stopped an update early, and below ~1e-16 A/m², nothing but noise.
4328
+ passedCurrent(k) {
4329
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.time, !this.atSteady);
4330
+ return d.kind === 'I' ? src : (src - this.termV[k]) / d.R;
4331
+ }
4332
+
4116
4333
  // Terminal current toward +x at the right contact, from the last assembly at this state.
4117
4334
  _terminalCurrent() {
4335
+ if (this.floating.includes(1)) return -this.passedCurrent(1);
4118
4336
  let I = 0;
4119
4337
  for (let i = 0; i < this.n; i++) I += FARADAY * this.z[i] * this.contactFlux.right[i];
4120
4338
  const dt = this.lastDt;
package/src/sources.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // driftlet/kit: sources as plain definition data. Waveforms for a terminal's V or I, an injector
2
2
  // port, and band-to-band recombination from a lifetime. Each returns an ordinary spec value.
3
3
 
4
- import { DeviceError } from './errors.js';
4
+ import { DeviceError, checkOptions } from './errors.js';
5
5
 
6
6
  const fail = (message) => {
7
7
  throw new DeviceError(message);
@@ -19,6 +19,7 @@ const num = (v) => typeof v === 'number' && Number.isFinite(v);
19
19
  * @returns {{ t: number[], values: number[] }}
20
20
  */
21
21
  export function pulse({ start = 0, width, amplitude, rise = 0, base = 0 } = {}) {
22
+ checkOptions('pulse', arguments[0], ['start', 'width', 'amplitude', 'rise', 'base']);
22
23
  need(num(width) && width > 0, `pulse: width must be > 0 (s), got ${width}`);
23
24
  need(num(amplitude), `pulse: amplitude must be a finite number (V or A/m²), got ${amplitude}`);
24
25
  need(num(start) && start >= 0, `pulse: start must be ≥ 0 (s), got ${start}`);
@@ -35,6 +36,7 @@ export function pulse({ start = 0, width, amplitude, rise = 0, base = 0 } = {})
35
36
  * @returns {{ t: number[], values: number[], repeat: true }}
36
37
  */
37
38
  export function square({ high, low, period, duty = 0.5 } = {}) {
39
+ checkOptions('square', arguments[0], ['high', 'low', 'period', 'duty']);
38
40
  need(num(high) && num(low), `square: high and low must be finite numbers, got ${high} and ${low}`);
39
41
  need(num(period) && period > 0, `square: period must be > 0 (s), got ${period}`);
40
42
  need(num(duty) && duty > 0 && duty < 1, `square: duty must be between 0 and 1, got ${duty}`);
@@ -49,6 +51,7 @@ export function square({ high, low, period, duty = 0.5 } = {}) {
49
51
  * @returns {{ t: number[], values: number[], repeat: true }}
50
52
  */
51
53
  export function triangle({ from, to, period } = {}) {
54
+ checkOptions('triangle', arguments[0], ['from', 'to', 'period']);
52
55
  need(num(from) && num(to), `triangle: from and to must be finite numbers, got ${from} and ${to}`);
53
56
  need(num(period) && period > 0, `triangle: period must be > 0 (s), got ${period}`);
54
57
  return { t: [0, period / 2, period], values: [from, to, from], repeat: true };
@@ -60,6 +63,7 @@ export function triangle({ from, to, period } = {}) {
60
63
  * @returns {{ t: number[], values: number[] }}
61
64
  */
62
65
  export function ramp({ from, to, duration, start = 0 } = {}) {
66
+ checkOptions('ramp', arguments[0], ['from', 'to', 'duration', 'start']);
63
67
  need(num(from) && num(to), `ramp: from and to must be finite numbers, got ${from} and ${to}`);
64
68
  need(num(duration) && duration > 0, `ramp: duration must be > 0 (s), got ${duration}`);
65
69
  need(num(start) && start >= 0, `ramp: start must be ≥ 0 (s), got ${start}`);
@@ -78,6 +82,7 @@ export function ramp({ from, to, duration, start = 0 } = {}) {
78
82
  * @returns {object} a port, for the definition's `ports`
79
83
  */
80
84
  export function injector({ name = 'injector', region, from, to, species, I, headroom = 2.5 } = {}) {
85
+ checkOptions('injector', arguments[0], ['name', 'region', 'from', 'to', 'species', 'I', 'headroom']);
81
86
  need(region !== undefined, 'injector: give the region (its name or index)');
82
87
  need(num(from) && num(to) && to > from, `injector: from and to must bound the window, in m from the region's left end (got ${from}, ${to})`);
83
88
  need(typeof species === 'string', 'injector: give the species it injects, e.g. species: \'h+\'');
@@ -96,6 +101,7 @@ export function injector({ name = 'injector', region, from, to, species, I, head
96
101
  * @returns {{ equation: string, kf: Record<string, number> }} a bulk reaction
97
102
  */
98
103
  export function recombination({ material, tau, majority } = {}) {
104
+ checkOptions('recombination', arguments[0], ['material', 'tau', 'majority']);
99
105
  need(typeof material === 'string', 'recombination: give the material, by name');
100
106
  need(num(tau) && tau > 0, `recombination: tau must be > 0 (s), got ${tau}`);
101
107
  need(num(majority) && majority > 0, `recombination: majority must be the majority carrier density > 0 (mol/m³; units.perCm3 converts), got ${majority}`);
@@ -117,6 +123,7 @@ export function recombination({ material, tau, majority } = {}) {
117
123
  * @returns {{ equation: string, fixed: Record<string, number>, kf: Record<string, { x: number[], values: number[] }> }} a bulk reaction
118
124
  */
119
125
  export function photogeneration({ material, flux, alpha, mu, from = 0, to, makes = 'e- + h+' } = {}) {
126
+ checkOptions('photogeneration', arguments[0], ['material', 'flux', 'alpha', 'mu', 'from', 'to', 'makes']);
120
127
  need(typeof material === 'string', 'photogeneration: give the material, by name');
121
128
  need(num(flux) && flux >= 0, `photogeneration: flux must be the photon flux ≥ 0 entering the material (mol/(m²·s)), got ${flux}`);
122
129
  need(num(alpha) && alpha > 0, `photogeneration: alpha must be the absorption coefficient > 0 (1/m; 1/cm × 100), got ${alpha}`);