driftlet 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solver.js CHANGED
@@ -503,6 +503,9 @@ export class Solver {
503
503
  this.termB = this.terms.map(() => new Float64Array(this.sys.size + 1));
504
504
  this.termC = this.terms.map(() => new Float64Array(this.sys.size + 1));
505
505
  this.termDI = new Float64Array(this.terms.length);
506
+ // (and a circuit row's derivatives by other terminals' voltages: a law in one's row that counts
507
+ // another electrode's charge)
508
+ this.termDX = this.terms.map(() => new Float64Array(this.terms.length));
506
509
  this.termRes = new Float64Array(this.terms.length);
507
510
  this._refreshSources();
508
511
  // A floating terminal starts level with the first held one (no current).
@@ -783,7 +786,7 @@ export class Solver {
783
786
  const ks = rx.part.map((p) => this.stretchOf[(f + p.side) * n + p.i]);
784
787
  if (ks.some((k) => k < 0)) continue;
785
788
  join(ks);
786
- rxns.push([ks, (side) => rx.part.reduce((a, p) => a + p.nu * this.contactEta(side, p.i), rx.fixedA)]);
789
+ rxns.push([ks, (side) => rx.part.reduce((a, p) => a - p.nu * this.contactEta(side, p.i), rx.fixedA)]); // (a face's ν: products +, so − Σ ν η)
787
790
  }
788
791
  });
789
792
  const groups = new Map();
@@ -834,6 +837,20 @@ export class Solver {
834
837
  return true;
835
838
  }
836
839
 
840
+ // The charge number an electrode's carrier column counts its charge in, Q/(zF): its terminal
841
+ // species', or 1 for a port with none (a membrane's capacitance: Q/F).
842
+ _carrierZ(k) {
843
+ const t = this.model.ports[k - 2].terminal;
844
+ return t === null || t === undefined ? 1 : this.z[t];
845
+ }
846
+
847
+ // Whether contact k (0, 1) holds a charge its current counts, D_in on its φ law (capacitive,
848
+ // pinned or bulk, where φ is defined at its end node).
849
+ _contactCharges(k) {
850
+ const g = k === 0 ? 0 : this.nNodes - 1;
851
+ return ['capacitive', 'pinned', 'bulk'].includes(this.model.contacts[k === 0 ? 'left' : 'right'].phi.type) && !this.phiUndefined[g];
852
+ }
853
+
837
854
  // Whether terminal k (a contact, or port k − 2) feeds stretch st: links its species there.
838
855
  _feeds(k, st) {
839
856
  return k === 0 ? st.leftOpen : k === 1 ? st.rightOpen : st.linked.includes(k - 2);
@@ -861,6 +878,19 @@ export class Solver {
861
878
  const driven = (st) => this.terms.flatMap((t, k) => (t.drive.kind === 'I' && this._feeds(k, st) ? [k] : []));
862
879
  const add = (parts, rowStretch, surface = [], circuit = -1, carriers = []) => {
863
880
  const st = this.stretches[rowStretch];
881
+ // (the terminal's weight in it, w/z on what that feeds: the amount changes by λ I/F)
882
+ const own = carriers.find((p) => p.k === circuit);
883
+ const lambda = circuit < 0 ? 0 : ((own && own.w / this._carrierZ(circuit)) || parts.map((p) => (driven(this.stretches[p.stretch]).includes(circuit) ? p.w / this.z[this.stretches[p.stretch].species] : 0)).find((x) => x !== 0));
884
+ // In a circuit row, what passes through that terminal's capacitance is kept too, λ Q/F, as
885
+ // its current counts dQ/dt: a port or contact at no current that passes K⁺ and charges keeps
886
+ // n_K + Q/F. (Its K⁺ alone held, a membrane port's voltage settled 7 mV from the transient's.)
887
+ // A port's charge, as an electrode's carrier; a contact's, its displacement D_in, below.
888
+ if (circuit >= 2 && model.ports[circuit - 2].capacitance && !carriers.some((p) => p.k === circuit)) carriers = [...carriers, { k: circuit, w: lambda * this._carrierZ(circuit) }];
889
+ // Every contact driven by a current that feeds it and holds a charge, D_in, kept beside the
890
+ // ions it passes at its own λ (two contacts at no current passing K⁺, one a capacitor: its
891
+ // charge was left out where the law took the other's row).
892
+ const lam = (t) => parts.map((p) => (driven(this.stretches[p.stretch]).includes(t) && this.z[this.stretches[p.stretch].species] !== 0 ? p.w / this.z[this.stretches[p.stretch].species] : 0)).find((x) => x !== 0);
893
+ const contacts = circuit < 0 ? [] : [0, 1].filter((t) => lam(t) && this._contactCharges(t)).map((t) => ({ t, lambda: lam(t) }));
864
894
  let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
865
895
  for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
866
896
  for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
@@ -869,11 +899,11 @@ export class Solver {
869
899
  parts,
870
900
  surface,
871
901
  carriers, // (what a port's electrode holds, Q/(zF) at weight w: see _conservedMoieties)
902
+ contacts, // (contacts whose charges it keeps, λ D_in/F each)
872
903
  terminals: [...new Set([...parts.flatMap((p) => driven(this.stretches[p.stretch])), ...carriers.map((p) => p.k)])],
873
904
  key: parts.map((p) => `${p.stretch}:${p.w}`).join() + surface.map((p) => `|${p.col}:${p.w}`).join() + carriers.map((p) => `|T${p.k}:${p.w}`).join(),
874
905
  circuit, // (the terminal whose circuit row it stands in for, or −1 for a balance row's, `row`)
875
- // (and the terminal's weight in it, w/z on what that feeds: the amount changes by λ I/F)
876
- lambda: circuit < 0 ? 0 : (carriers.find((p) => p.k === circuit)?.w / this.z[model.ports[circuit - 2]?.terminal] || parts.map((p) => (driven(this.stretches[p.stretch]).includes(circuit) ? p.w / this.z[this.stretches[p.stretch].species] : 0)).find((x) => x !== 0)),
906
+ lambda,
877
907
  row: circuit >= 0 ? -1 : this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
878
908
  idx: new Int32Array(nNodes * this.M),
879
909
  w: new Float64Array(nNodes * this.M),
@@ -890,7 +920,12 @@ export class Solver {
890
920
  if (this.flatStretches.includes(st)) this.constraints.at(-1).col = Array.from({ length: st.nodes[1] - st.nodes[0] + 1 }, (_, j) => this.blockOfNode[st.nodes[0] + j] * this.M + 1 + st.species);
891
921
  });
892
922
  const S = this.stretches.length, T = this.terms.length, X = S + this.surfCols.length;
893
- const zc = (k) => this.z[model.ports[k - 2].terminal];
923
+ // Two column spaces. A law's weights w: stretches [0, S), electrode surfaces' species [S, X)
924
+ // (surface column k − S), electrodes' charges [X, …) (carrier k − X). The rows a law can take,
925
+ // v: stretches' balance rows [0, S), then terminals' circuit rows (terminal j − S).
926
+ const isStretch = (k) => k < S, isSurface = (k) => k >= S && k < X, isCarrier = (k) => k >= X;
927
+ const circuitOf = (j) => (j >= S ? j - S : -1);
928
+ const zc = (k) => this._carrierZ(k);
894
929
  // The rows replaced: a moving stretch's balance (one of still stretches alone is kept node by
895
930
  // node, below), or the circuit row of a terminal driven at no current, which says nothing the
896
931
  // combination's balance rows don't (their weighted sum is that terminal's current, weighted
@@ -940,11 +975,16 @@ export class Solver {
940
975
  // A current held at zero passes nothing: an electrode at open circuit feeding a closed
941
976
  // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
942
977
  const passes = (k) => !this._noCurrent(this.terms[k]);
943
- const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && driven(this.stretches[k]).some(passes))));
978
+ const fed = (w) => w.some((x, k) => x !== 0 && ((isCarrier(k) && passes(this.carriers[k - X])) || (isStretch(k) && driven(this.stretches[k]).some(passes))));
944
979
  // Any of the combinations it alone changes will do, with any conserved amount added (whose
945
980
  // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
946
981
  // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
947
982
  const news = this._conservedMoieties(true, [], rx).filter((w) => Math.abs(nu.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9 && !fed(w));
983
+ // (One it changes only through immobile species has its rate alone in their balance at the
984
+ // face's node already, a row with no diffusion beside it to lose it to: left as it is. Stood
985
+ // in for by a mobile partner, Ag⁺ + Cl⁻ = S(immobile) beside a floating Ag electrode said its
986
+ // rate twice and lost the electrode's I = 0.)
987
+ if (news.some((w) => [...w.keys()].every((k) => !isStretch(k) || w[k] === 0 || this._still(this.stretches[k])))) continue;
948
988
  const kept = laws.filter((l) => !fed(l));
949
989
  const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
950
990
  for (const w of tries) {
@@ -976,6 +1016,7 @@ export class Solver {
976
1016
  break;
977
1017
  }
978
1018
  }
1019
+ this.contactQ = [null, null];
979
1020
  for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
980
1021
  for (const [j, k] of this.carriers.entries()) {
981
1022
  const c = held.get(k);
@@ -984,10 +1025,13 @@ export class Solver {
984
1025
  w = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
985
1026
  w.own = own;
986
1027
  }
987
- const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
988
- const surface = [...w.keys()].filter((k) => k >= S && k < X && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
1028
+ const parts = [...w.keys()].filter((k) => isStretch(k) && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
1029
+ const surface = [...w.keys()].filter((k) => isSurface(k) && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
989
1030
  const carriers = this.carriers.flatMap((k, j) => (w[X + j] !== 0 ? [{ k, w: w[X + j] }] : []));
990
- if (parts.length === 0) continue; // (a surface's own: node by node, below)
1031
+ // (A surface's own is kept node by node, below; one with an electrode's charge in it too is
1032
+ // the circuit's, here: below skips it, and a floating electrode whose adsorbates gave it
1033
+ // electrons settled 15 mV from where the transient ends.)
1034
+ if (parts.length === 0 && carriers.length === 0) continue;
991
1035
  if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
992
1036
  if (parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
993
1037
  const v = new Float64Array(S + T);
@@ -1005,11 +1049,14 @@ export class Solver {
1005
1049
  if (!(scale > 1e-9)) continue;
1006
1050
  const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
1007
1051
  const col = [...Array(T).keys()].map((t) => S + t).find(ok) ?? [parts.find((p) => w[p.stretch] === 1)?.stretch, w.own].find(ok) ?? v.findIndex((x) => Math.abs(x) === scale);
1008
- if (carriers.some((p) => p.k !== col - S)) continue; // (an electrode's charge in a circuit row not its own: never, as they're chosen)
1052
+ // (Other electrodes' charges in a circuit row not their own: their voltages enter it through
1053
+ // termDX. Two floating electrodes sharing a closed Fe³⁺, their law dropped, settled at 1.3 V
1054
+ // for −0.65.)
1009
1055
  chosen.push([col, v.map((x) => x / v[col])]);
1010
- if (col >= S) held.set(col - S, w);
1011
- add(parts, col < S ? col : parts[0].stretch, surface, col < S ? -1 : col - S, carriers);
1056
+ if (circuitOf(col) >= 0) held.set(circuitOf(col), w);
1057
+ add(parts, circuitOf(col) < 0 ? col : parts[0]?.stretch, surface, circuitOf(col), carriers);
1012
1058
  }
1059
+ this.contactLaw = [0, 1].map((k) => this.constraints.some((cs) => cs.contacts.some((c) => c.t === k)));
1013
1060
  // A combination that nothing carries anywhere is conserved node by node: of still stretches
1014
1061
  // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
1015
1062
  // that only turn into each other). At each node its weighted sum stays what it was, a row local
@@ -1018,18 +1065,47 @@ export class Solver {
1018
1065
  const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
1019
1066
  for (const w of laws) {
1020
1067
  const parts = [...w.keys()].filter((k) => w[k] !== 0);
1021
- if (parts.some((k) => k >= X)) continue; // (through an electrode: above)
1022
- const sets = parts.map((k) => (k >= S ? model.ports[this.surfCols[k - S][0]].nodes : this._still(this.stretches[k]) ? span(this.stretches[k].nodes) : null));
1023
- if (sets.some((set) => set === null || set.length !== sets[0].length || set.some((g, j) => g !== sets[0][j]))) continue; // (side by side only)
1024
- for (const k of parts) if (k < S) this.stretches[k].conserved = true;
1025
- const slot = (k) => (k < S ? 1 + this.stretches[k].species : 1 + this.n + this.surfCols[k - S][1]);
1068
+ if (parts.some(isCarrier)) continue; // (through an electrode: above)
1069
+ const sets = parts.map((k) => (isSurface(k) ? model.ports[this.surfCols[k - S][0]].nodes : this._still(this.stretches[k]) ? span(this.stretches[k].nodes) : null));
1070
+ if (sets.includes(null)) continue;
1071
+ // Its nodes: the immobile stretches', side by side, with any surface's window among them (a
1072
+ // surface's term only where it is: an immobile S that adsorbs on an electrode spread over
1073
+ // part of its region keeps S + Γaθ there and S alone elsewhere; required to match, that law
1074
+ // was lost, and a floating electrode's coverage drifted). Or a surface's alone.
1075
+ const own = parts.filter(isStretch), base = own.length > 0 ? sets[parts.indexOf(own[0])] : sets[0];
1076
+ const same = (set) => set.length === base.length && set.every((g, j) => g === base[j]);
1077
+ if (!parts.every((k, j) => (isStretch(k) ? same(sets[j]) : sets[j].every((g) => base.includes(g))))) continue;
1078
+ for (const k of own) this.stretches[k].conserved = true;
1079
+ const slot = (k) => (isStretch(k) ? 1 + this.stretches[k].species : 1 + this.n + this.surfCols[k - S][1]);
1080
+ // (its row a stretch's where it has one, present at every node; else the surface's)
1081
+ const rowPart = own.length > 0 ? (own.includes(w.own) ? w.own : own[0]) : parts.includes(w.own) ? w.own : parts[0];
1026
1082
  this.localConstraints.push({
1027
- parts: parts.map((k) => (k < S ? { i: this.stretches[k].species, w: w[k] } : { q: this.surfCols[k - S][1], w: w[k] })),
1028
- row: slot(parts.includes(w.own) ? w.own : parts[0]),
1029
- nodes: sets[0],
1083
+ parts: parts.map((k) => (isStretch(k) ? { i: this.stretches[k].species, w: w[k] } : { q: this.surfCols[k - S][1], k: this.surfCols[k - S][0], w: w[k] })),
1084
+ row: slot(rowPart),
1085
+ nodes: base,
1030
1086
  reference: new Float64Array(sets[0].length),
1031
1087
  });
1032
1088
  }
1089
+ // An immobile stretch at a node where nothing touches it (no reaction there, no port or contact
1090
+ // linking it) keeps its concentration: its balance there is 0 = 0 in a steady state, which
1091
+ // holds nothing, and a charged one's level stayed where it was while φ moved (S⁺ beside a face
1092
+ // that makes it, the bath moved 0.2 V: S⁺ fell to e^{−0.2/V_T} of itself away from the face).
1093
+ for (const st of this.stretches) {
1094
+ if (!this._still(st)) continue;
1095
+ const i = st.species, touched = new Set();
1096
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) if (this.rxsIn[this.nodeMaterial[g]].some((rx) => rx.sp.includes(i))) touched.add(g);
1097
+ model.interfaces.forEach((itf, f) => {
1098
+ for (const rx of itf.reactions) for (const p of rx.part) if (p.i === i) touched.add(p.side === 0 ? grid.regionEnd[f] : grid.regionStart[f + 1]);
1099
+ });
1100
+ for (const port of model.ports) {
1101
+ if (port.species[i].type !== 'blocked' || port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i))) for (const g of port.nodes) touched.add(g);
1102
+ }
1103
+ if (st.leftOpen) touched.add(0);
1104
+ if (st.rightOpen) touched.add(this.nNodes - 1);
1105
+ for (const lc of this.localConstraints) if (lc.row === 1 + i) for (const g of lc.nodes) touched.add(g);
1106
+ const free = span(st.nodes).filter((g) => !touched.has(g));
1107
+ if (free.length > 0) this.localConstraints.push({ parts: [{ i, w: 1 }], row: 1 + i, nodes: free, reference: new Float64Array(free.length) });
1108
+ }
1033
1109
  // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
1034
1110
  // contact, port or reaction) holds its level only through its faces' fluxes, which can be
1035
1111
  // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
@@ -1108,7 +1184,7 @@ export class Solver {
1108
1184
  if (fedBy.size !== 1) continue;
1109
1185
  const [k] = fedBy;
1110
1186
  if (!behind.includes(k) || this.timeLaws.some((cs) => cs.circuit === k)) continue;
1111
- const zc = k >= 2 ? this.z[model.ports[k - 2].terminal] : 0;
1187
+ const zc = k >= 2 ? this._carrierZ(k) : 0;
1112
1188
  const lambda = carriers.find((p) => p.k === k)?.w / zc || parts.map((p) => (this._feeds(k, this.stretches[p.stretch]) && this.z[this.stretches[p.stretch].species] !== 0 ? p.w / this.z[this.stretches[p.stretch].species] : 0)).find((x) => x !== 0);
1113
1189
  if (!lambda) continue;
1114
1190
  let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
@@ -1131,13 +1207,14 @@ export class Solver {
1131
1207
  for (const k of charged) this.chargeRefs.set(k, k >= 2 ? this.portQ[k - 2] : k === 0 ? this.contactD.left : -this.contactD.right);
1132
1208
  }
1133
1209
  this.lawRefs.clear();
1134
- if (this.constraints.some((cs) => cs.carriers.some(({ k }) => this.model.ports[k - 2].capacitance)) && charged.length === 0) this._assembleBookkeeping(Infinity);
1210
+ if (this.constraints.some((cs) => cs.contacts.length > 0 || cs.carriers.some(({ k }) => this.model.ports[k - 2].capacitance)) && charged.length === 0) this._assembleBookkeeping(Infinity);
1135
1211
  for (const cs of this.constraints) {
1136
1212
  if (cs.terminals.length === 0) continue;
1137
1213
  let a = 0;
1138
1214
  for (const { stretch, w } of cs.parts) a += w * this.amount(this.stretches[stretch]);
1139
1215
  for (const { col, w } of cs.surface) a += w * this.surfaceAmount(col);
1140
- for (const { k, w } of cs.carriers) if (this.model.ports[k - 2].capacitance) a += (w * this.portQ[k - 2]) / (this.z[this.model.ports[k - 2].terminal] * FARADAY);
1216
+ for (const { k, w } of cs.carriers) if (this.model.ports[k - 2].capacitance) a += (w * this.portQ[k - 2]) / (this._carrierZ(k) * FARADAY);
1217
+ for (const { t, lambda } of cs.contacts) a += (lambda * (t === 0 ? this.contactD.left : -this.contactD.right)) / FARADAY;
1141
1218
  this.lawRefs.set(cs.key, a);
1142
1219
  }
1143
1220
  }
@@ -1149,7 +1226,10 @@ export class Solver {
1149
1226
  for (const lc of this.localConstraints) {
1150
1227
  lc.nodes.forEach((g, j) => {
1151
1228
  let t = 0;
1152
- for (const { i, q, w } of lc.parts) t += w * (i !== undefined ? c[g * n + i] : th[g * nSurf + q]);
1229
+ for (const { i, q, k, w } of lc.parts) {
1230
+ if (i !== undefined) t += w * c[g * n + i];
1231
+ else if (this.surfPort[g] === k) t += w * this._sites(g) * th[g * nSurf + q];
1232
+ }
1153
1233
  lc.reference[j] = t;
1154
1234
  });
1155
1235
  }
@@ -1220,6 +1300,14 @@ export class Solver {
1220
1300
  }
1221
1301
  }
1222
1302
 
1303
+ // An electrode surface's sites per volume at node g, Γ a (mol/m³): what a coverage of 1 holds,
1304
+ // beside the concentrations of a local constraint. (Summed as θ alone, an immobile S that adsorbs
1305
+ // as A(ads) kept S + θ_A, mol/m³ and a fraction.)
1306
+ _sites(g) {
1307
+ const k = this.surfPort[g];
1308
+ return this.model.ports[k].surface[0].capacity * this.portArea[k][this.surfW[g]];
1309
+ }
1310
+
1223
1311
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
1224
1312
  _applyLocalConstraints() {
1225
1313
  const { n, M, c, th, nSurf, res, dA: d } = this;
@@ -1229,17 +1317,18 @@ export class Solver {
1229
1317
  if (this.loc[b * M + lc.row] < 0) return;
1230
1318
  this._replaceRow(b, lc.row);
1231
1319
  let t = 0;
1232
- for (const { i, q, w } of lc.parts) {
1320
+ for (const { i, q, k, w } of lc.parts) {
1233
1321
  if (i !== undefined) {
1234
1322
  t += w * c[g * n + i];
1235
1323
  this._dc(g, i, d);
1236
1324
  for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1237
1325
  continue;
1238
1326
  }
1239
- // A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species.
1240
- const tq = th[g * nSurf + q], ns = this.model.ports[this.surfPort[g]].surface.length;
1241
- t += w * tq;
1242
- for (let x = 0; x < ns; x++) this._j(b, lc.row, b, 1 + n + x, w * tq * ((q === x ? 1 : 0) - th[g * nSurf + x]));
1327
+ // A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species (where it is).
1328
+ if (this.surfPort[g] !== k) continue;
1329
+ const tq = th[g * nSurf + q], ns = this.model.ports[this.surfPort[g]].surface.length, ws = w * this._sites(g);
1330
+ t += ws * tq;
1331
+ for (let x = 0; x < ns; x++) this._j(b, lc.row, b, 1 + n + x, ws * tq * ((q === x ? 1 : 0) - th[g * nSurf + x]));
1243
1332
  }
1244
1333
  res[this.rix[b * M + lc.row]] = t - lc.reference[j];
1245
1334
  });
@@ -1261,8 +1350,12 @@ export class Solver {
1261
1350
  // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
1262
1351
  // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
1263
1352
  const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
1264
- this.carriers = drives ? this.terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && model.ports[k - 2].reactions.length > 0 ? [k] : [])) : [];
1353
+ // (Or a port with a capacitance that passes ions, a membrane's: what it holds is Q/F. One that
1354
+ // only charges keeps its charge alone, see _captureLaws.)
1355
+ this.carriers = drives ? this.terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && (model.ports[k - 2].reactions.length > 0 || (model.ports[k - 2].capacitance && !this._chargeOnly(k))) ? [k] : [])) : [];
1265
1356
  const X = S + this.surfCols.length, C = X + this.carriers.length;
1357
+ // (columns: stretches [0, S), surfaces' species [S, X), electrodes' charges [X, C), as in _laws)
1358
+ const isStretch = (k) => k < S, isSurface = (k) => k >= S && k < X;
1266
1359
  // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1267
1360
  // its own rows, over the columns, are handed back as `basis.skipped`.)
1268
1361
  const rows = [], skipped = [];
@@ -1306,7 +1399,7 @@ export class Solver {
1306
1399
  const fed = this.stretches.flatMap((st, j) => (this._feeds(k, st) ? [j] : []));
1307
1400
  if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
1308
1401
  const charged = fed.filter((j) => z[this.stretches[j].species] !== 0).map((j) => [j, z[this.stretches[j].species]]);
1309
- if (this.carriers.includes(k)) charged.push([X + this.carriers.indexOf(k), z[model.ports[k - 2].terminal]]);
1402
+ if (this.carriers.includes(k)) charged.push([X + this.carriers.indexOf(k), this._carrierZ(k)]);
1310
1403
  fed.filter((j) => z[this.stretches[j].species] === 0).forEach(unit);
1311
1404
  for (let a = 1; a < charged.length; a++) {
1312
1405
  const row = new Float64Array(C);
@@ -1316,16 +1409,18 @@ export class Solver {
1316
1409
  }
1317
1410
  });
1318
1411
  // Reduced row echelon form; the free columns give the null space. Immobile stretches' columns
1319
- // go first, so that a combination of them alone (trap states, X⁰ + X⁻) comes out on its own,
1320
- // to be kept node by node, apart from any that moves (e⁻ − X⁰, the electrons the traps hold).
1412
+ // go first, and electrode surfaces' (as local), so that a combination of them alone (trap
1413
+ // states, X⁰ + X⁻; adsorbates, A(ads) + B(ads)) comes out on its own, to be kept node by node,
1414
+ // apart from any that moves (e⁻ − X⁰, the electrons the traps hold). (Pivoted after a closed
1415
+ // Cl⁻ in A(ads) + Cl⁻ = B(ads) + e⁻, the surface's own came out mixed with it, and was lost.)
1321
1416
  // Then the stretches by their starting amounts, least first, and by name: a trace species
1322
1417
  // pivoted after an abundant one comes out added to it (A + B ⇌ C with B at 1 M and A at 1e-14
1323
1418
  // gave B + C and A − B, where A + C was lost to round-off), and the basis followed the listing.
1324
- const still = (k) => (k < S && this._still(this.stretches[k]) ? 1 : 0);
1419
+ const still = (k) => ((isStretch(k) && this._still(this.stretches[k])) || isSurface(k) ? 1 : 0);
1325
1420
  const rough = this.stretches.map((st) => this._roughAmount(st)), name = (k) => model.species[this.stretches[k].species].name;
1326
1421
  const order = [...Array(C).keys()].sort((a, b) => {
1327
1422
  if (still(a) !== still(b)) return still(b) - still(a);
1328
- if (a >= S || b >= S) return a - b;
1423
+ if (!isStretch(a) || !isStretch(b)) return a - b;
1329
1424
  if (rough[a] !== rough[b]) return rough[a] - rough[b];
1330
1425
  const na = name(a), nb = name(b);
1331
1426
  return na < nb ? -1 : na > nb ? 1 : a - b;
@@ -1386,8 +1481,16 @@ export class Solver {
1386
1481
  const C = this.termC[cs.circuit];
1387
1482
  C.fill(0);
1388
1483
  for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += cs.w[j];
1484
+ for (const { t, lambda } of cs.contacts) {
1485
+ // A contact's charge, D_in, as _contact had it, with its derivatives.
1486
+ const q = this.contactQ[t], f = lambda / FARADAY;
1487
+ cs.res += f * q.D;
1488
+ for (let j = 0; j < C.length; j++) C[j] += f * q.row[j];
1489
+ dV[t] += f * q.dV;
1490
+ }
1389
1491
  this.termRes[cs.circuit] = cs.res;
1390
- this.termDI[cs.circuit] = dV;
1492
+ this.termDI[cs.circuit] = dV[cs.circuit];
1493
+ dV.forEach((x, k) => k !== cs.circuit && (this.termDX[cs.circuit][k] = x));
1391
1494
  continue;
1392
1495
  }
1393
1496
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
@@ -1445,24 +1548,25 @@ export class Solver {
1445
1548
  }
1446
1549
  // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
1447
1550
  // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as _portCapacitance has it).
1448
- let dV = 0;
1551
+ const dV = cs.dV ?? (cs.dV = new Float64Array(this.terms.length));
1552
+ dV.fill(0);
1449
1553
  for (const { k, w } of cs.carriers) {
1450
1554
  const port = this.model.ports[k - 2];
1451
1555
  if (!port.capacitance) continue;
1452
- const f = w / (this.z[port.terminal] * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k];
1556
+ const f = w / (this._carrierZ(k) * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k];
1453
1557
  const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
1454
1558
  port.nodes.forEach((g, j) => {
1455
1559
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
1456
1560
  const b = this.blockOfNode[g], s = vol[g] * this.portArea[k - 2][j] * Cs;
1457
1561
  amount += f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M]));
1458
1562
  gross += Math.abs(f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M])));
1459
- dV += f * s;
1563
+ dV[k] += f * s;
1460
1564
  cs.idx[len] = this.rix[b * M];
1461
1565
  cs.w[len++] = -f * s * this.VT;
1462
1566
  });
1463
1567
  }
1464
1568
  cs.len = len;
1465
- return { amount, reference, dV, gross };
1569
+ return { amount, reference, dV, gross }; // (dV by terminal)
1466
1570
  }
1467
1571
 
1468
1572
  // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
@@ -1477,7 +1581,7 @@ export class Solver {
1477
1581
  const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity;
1478
1582
  port.nodes.forEach((g, j) => (a += w * G * vol[g] * this.portArea[k][j] * this.thOld[g * nSurf + q]));
1479
1583
  }
1480
- for (const { k, w } of cs.carriers) if (this.model.ports[k - 2].capacitance) a += (w * this.portQStart[k - 2]) / (this.z[this.model.ports[k - 2].terminal] * FARADAY);
1584
+ for (const { k, w } of cs.carriers) if (this.model.ports[k - 2].capacitance) a += (w * this.portQStart[k - 2]) / (this._carrierZ(k) * FARADAY);
1481
1585
  return a;
1482
1586
  }
1483
1587
 
@@ -1497,7 +1601,7 @@ export class Solver {
1497
1601
  _circuitLaws(dt) {
1498
1602
  for (const cs of [...this.constraints, ...this.timeLaws]) {
1499
1603
  const k = cs.circuit;
1500
- if (k < 0 || !this.floating.includes(k)) continue;
1604
+ if (k < 0 || !this.floating.includes(k) || cs.contacts?.length > 0) continue; // (a contact's charge: its own rows)
1501
1605
  const { amount, dV, gross } = this._lawAmount(cs), f = FARADAY / (cs.lambda * dt), C = this.termC[k];
1502
1606
  if (!this.lawForm.has(cs)) {
1503
1607
  let G = 0;
@@ -1509,7 +1613,8 @@ export class Solver {
1509
1613
  for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
1510
1614
  const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1511
1615
  this.termRes[k] = f * (amount - this._lawStart(cs)) - (d.kind === 'I' ? src : (src - this.termV[k]) / d.R);
1512
- this.termDI[k] = f * dV + (d.kind === 'I' ? 0 : 1 / d.R);
1616
+ this.termDI[k] = f * dV[k] + (d.kind === 'I' ? 0 : 1 / d.R);
1617
+ dV.forEach((x, j) => j !== k && (this.termDX[k][j] = f * x));
1513
1618
  }
1514
1619
  }
1515
1620
 
@@ -1553,7 +1658,10 @@ export class Solver {
1553
1658
  const A = Array.from({ length: S }, () => new Float64Array(S + 1));
1554
1659
  for (let a = 0; a < S; a++) {
1555
1660
  for (let b = 0; b < S; b++) A[a][b] = (b < P ? 1 : -1) * dot(a, cols[b]);
1556
- if (a >= P) A[a][a] += this.termDI[fl[a - P]];
1661
+ if (a >= P) {
1662
+ A[a][a] += this.termDI[fl[a - P]];
1663
+ for (let b = P; b < S; b++) if (b !== a) A[a][b] += this.termDX[fl[a - P]][fl[b - P]];
1664
+ }
1557
1665
  A[a][S] = (a < P ? pins[a].res : this.termRes[fl[a - P]]) - dot(a, delta);
1558
1666
  let mx = 0;
1559
1667
  for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
@@ -3082,6 +3190,7 @@ export class Solver {
3082
3190
  this.termC[k].fill(0);
3083
3191
  }
3084
3192
  this.termDI.fill(0);
3193
+ for (const x of this.termDX) x.fill(0);
3085
3194
  this.termI.fill(0);
3086
3195
  this.qg.fill(0);
3087
3196
  this.qgSlope.fill(0);
@@ -3413,6 +3522,13 @@ export class Solver {
3413
3522
  // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
3414
3523
  // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
3415
3524
  const link = ct.phi, Dstart = sgn * this.contactDStart[side];
3525
+ // (In a steady solve, what a law in this circuit row counts of D_in: see _applyConstraints.)
3526
+ const q = !dyn && this.steady && this.contactLaw[k] ? (this.contactQ[k] ??= { row: new Float64Array(this.sys.size + 1), D: 0, dV: 0 }) : null;
3527
+ if (q) {
3528
+ q.row.fill(0);
3529
+ q.D = 0;
3530
+ q.dV = 0;
3531
+ }
3416
3532
  if (this.phiUndefined[g]) {
3417
3533
  this.contactD[side] = 0;
3418
3534
  } else if (link.type === 'capacitive') {
@@ -3422,6 +3538,7 @@ export class Solver {
3422
3538
  this._j(b, 0, b, 0, Ac * link.C * VT);
3423
3539
  B[R[b * M]] += -Ac * link.C;
3424
3540
  this.contactD[side] = sgn * Din;
3541
+ if (q && this.loc[b * M] >= 0) [q.D, q.row[R[b * M]], q.dV] = [Din, -Ac * link.C * VT, Ac * link.C];
3425
3542
  // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
3426
3543
  // the charge it keeps in place of that (see _captureLaws).
3427
3544
  const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
@@ -3434,6 +3551,10 @@ export class Solver {
3434
3551
  // The Poisson residual is the outside's charge, D_in.
3435
3552
  const Din = res[R[b * M]];
3436
3553
  this.contactD[side] = sgn * Din;
3554
+ if (q) {
3555
+ q.D = Din;
3556
+ this._captureRow(b, 0, 1, q.row);
3557
+ }
3437
3558
  if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
3438
3559
  I += (Din - Dstart) / dt;
3439
3560
  this._captureRow(b, 0, 1 / dt, C);
@@ -3625,13 +3746,14 @@ export class Solver {
3625
3746
  _refine(dt, delta, deltaV) {
3626
3747
  const { res, termRes, termB, termC, termDI, rowDiv, floating: fl } = this;
3627
3748
  const N = this.sys.size, K = fl.length, pins = this.pins, R = this.rix;
3628
- const save = { res: Float64Array.from(res), termRes: Float64Array.from(termRes), termB: termB.map((b) => Float64Array.from(b)), termC: termC.map((c) => Float64Array.from(c)), termDI: Float64Array.from(termDI), transformed: this.transformed, pinRes: this.pins.map((p) => p.res) };
3749
+ const save = { res: Float64Array.from(res), termRes: Float64Array.from(termRes), termB: termB.map((b) => Float64Array.from(b)), termC: termC.map((c) => Float64Array.from(c)), termDI: Float64Array.from(termDI), termDX: this.termDX.map((x) => Float64Array.from(x)), transformed: this.transformed, pinRes: this.pins.map((p) => p.res) };
3629
3750
  const restoreArrays = () => {
3630
3751
  res.set(save.res);
3631
3752
  termRes.set(save.termRes);
3632
3753
  save.termB.forEach((b, k) => termB[k].set(b));
3633
3754
  save.termC.forEach((c, k) => termC[k].set(c));
3634
3755
  termDI.set(save.termDI);
3756
+ save.termDX.forEach((x, k) => this.termDX[k].set(x));
3635
3757
  this.transformed = save.transformed;
3636
3758
  this.pins.forEach((p, a) => (p.res = save.pinRes[a]));
3637
3759
  };
@@ -3659,6 +3781,7 @@ export class Solver {
3659
3781
  }
3660
3782
  for (let a = 0; a < K; a++) {
3661
3783
  let t = Dk[a] * x[N + a];
3784
+ for (let b = 0; b < K; b++) if (b !== a) t += this.termDX[fl[a]][fl[b]] * x[N + b];
3662
3785
  for (let j = 0; j < N; j++) t += Ck[a][j] * x[j];
3663
3786
  out[N + a] = t;
3664
3787
  }
package/src/stack.js CHANGED
@@ -33,6 +33,9 @@ export function combine(...parts) {
33
33
  const species = [], materials = {};
34
34
  parts.forEach((part, k) => {
35
35
  if (!isObject(part)) fail(`combine: part ${k} must be { species, materials }`);
36
+ // (trap()'s whole result, given for its library, dropped the traps without a word)
37
+ if (part.library !== undefined) fail(`combine: part ${k} is { library, … }, as trap() returns: give its library here (trap(…).library), and its bulkReactions and c0 to the device`);
38
+ fields(part, `combine: part ${k}`, ['species', 'materials']);
36
39
  for (const sp of part.species ?? []) {
37
40
  const prev = species.find((s) => s.name === sp.name);
38
41
  if (!prev) species.push({ ...sp });
@@ -100,6 +103,7 @@ export function ohmic(d = 0, carriers = ['e-', 'h+']) {
100
103
  */
101
104
  export function bath(c, reference, d = 0, opts = {}) {
102
105
  if (typeof reference !== 'string') [d, opts] = [reference ?? 0, isObject(d) ? d : {}];
106
+ checkOptions('bath', opts, ['offset', 'material']);
103
107
  const { offset, material } = opts;
104
108
  return {
105
109
  ...drive(d, 'bath'),
@@ -161,7 +165,7 @@ export function build(def) {
161
165
  if (regions.length === 0) fail('build.stack has no layers');
162
166
  // A contact holding a carrier its layer lacks: most often ohmic()'s default on a metal.
163
167
  for (const [k, end] of [[0, regions[0]], [stack.length - 1, regions[regions.length - 1]]]) {
164
- const held = isObject(stack[k].species) ? Object.keys(stack[k].species).filter((s) => stack[k].species[s] !== 'blocked') : [];
168
+ const held = isObject(stack[k].species) ? Object.keys(stack[k].species).filter((s) => stack[k].species[s] !== 'blocked' && stack[k].species[s]?.type !== 'blocked') : [];
165
169
  const mat = materials[end.material];
166
170
  if (!isObject(mat)) continue;
167
171
  const has = (s) => (isObject(mat.conductor) ? mat.conductor.species === s : isObject(mat.species) && mat.species[s] !== undefined);
package/src/statistics.js CHANGED
@@ -286,7 +286,7 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
286
286
  idx,
287
287
  evaluate(zeta, c, K) {
288
288
  for (let b = 0; b < k; b++) y[b] = zeta[b] + Math.log(cRef[b]);
289
- let st;
289
+ let st, mx = Infinity;
290
290
  for (let it = 0; it < 100; it++) {
291
291
  for (let b = 0; b < k; b++) cc[b] = Math.exp(y[b]);
292
292
  st = state(cc);
@@ -297,7 +297,7 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
297
297
  R[b] = y[b] + lng[b] - zeta[b] - Math.log(cRef[b]);
298
298
  dot += s[b] * cc[b] * R[b];
299
299
  }
300
- let mx = 0;
300
+ mx = 0;
301
301
  for (let b = 0; b < k; b++) {
302
302
  let dy = R[b] + (st.A * s[b] * dot) / st.denom;
303
303
  if (dy > 1) dy = 1;
@@ -307,6 +307,8 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
307
307
  }
308
308
  if (mx < 1e-14) break;
309
309
  }
310
+ // (a stall at round-off, near the convexity limit, is converged; anything more isn't)
311
+ if (!(mx < 1e-10)) throw new SolverError(`debye-huckel: the concentrations at these levels did not converge (a step of ${mx.toExponential(2)} in ln c after 100 iterations)`);
310
312
  for (let b = 0; b < k; b++) c[b] = Math.exp(y[b]);
311
313
  st = state(c);
312
314
  const f = st.A / st.denom;
@@ -315,18 +317,37 @@ function debyeHuckelModel(idx, cRef, z, epsr, a, RT) {
315
317
  }
316
318
  },
317
319
  invert(zeta, fixed, target) {
318
- for (let b = 0; b < k; b++) cc[b] = fixed[b] ? target[b] : cRef[b] * Math.exp(zeta[b]);
319
- for (let it = 0; it < 500; it++) {
320
- state(cc);
321
- let change = 0;
320
+ // The free species by Newton in y = ln c, as evaluate() does, the fixed held at their targets:
321
+ // J = I − A s_F (s∘c)_Fᵀ over the free ones alone. (A fixed-point iteration contracted by
322
+ // 1 − denom, and near the convexity limit stopped short without a word.)
323
+ for (let b = 0; b < k; b++) {
324
+ y[b] = fixed[b] ? Math.log(target[b]) : zeta[b] + Math.log(cRef[b]);
325
+ cc[b] = Math.exp(y[b]);
326
+ }
327
+ let mx = 0;
328
+ for (let it = 0; it < 100; it++) {
329
+ const st = state(cc);
330
+ let dot = 0, s2cF = 0;
322
331
  for (let b = 0; b < k; b++) {
323
332
  if (fixed[b]) continue;
324
- const next = cRef[b] * Math.exp(zeta[b] - lng[b]);
325
- if (cc[b] > 0) change = Math.max(change, Math.abs(next / cc[b] - 1));
326
- cc[b] = next;
333
+ lng[b] = y[b] + lng[b] - zeta[b] - Math.log(cRef[b]); // (the residual, in lng's place)
334
+ dot += s[b] * cc[b] * lng[b];
335
+ s2cF += s[b] * s[b] * cc[b];
327
336
  }
328
- if (!(change > 1e-15)) break;
337
+ const dF = 1 - st.A * s2cF; // (≥ denom > 0)
338
+ mx = 0;
339
+ for (let b = 0; b < k; b++) {
340
+ if (fixed[b]) continue;
341
+ let dy = lng[b] + (st.A * s[b] * dot) / dF;
342
+ if (dy > 1) dy = 1;
343
+ else if (dy < -1) dy = -1;
344
+ y[b] -= dy;
345
+ cc[b] = Math.exp(y[b]);
346
+ mx = Math.max(mx, Math.abs(dy));
347
+ }
348
+ if (mx < 1e-14) break;
329
349
  }
350
+ if (!(mx < 1e-10)) throw new SolverError(`debye-huckel: the composition given did not converge to its levels (a step of ${mx.toExponential(2)} in ln c after 100 iterations)`);
330
351
  state(cc);
331
352
  for (let b = 0; b < k; b++) if (fixed[b]) zeta[b] = Math.log(target[b] / cRef[b]) + lng[b];
332
353
  },
package/src/traces.js CHANGED
@@ -37,6 +37,8 @@ const finite = (a) => {
37
37
  */
38
38
  export function speciesRole(sol, name) {
39
39
  const z = Object.fromEntries(sol.species.map((sp) => [sp.name, sp.z]));
40
+ if (!(name in z)) throw new DeviceError(`speciesRole: no species '${name}' in the solution`);
41
+ if (z[name] === 0) throw new DeviceError(`speciesRole: '${name}' is neutral, with no role in level diagrams (draw it muted, or in a colour of your own)`);
40
42
  const role = (n) => (n === 'e-' ? 'electron' : z[n] > 0 ? 'cation' : 'anion');
41
43
  const charged = sol.species.filter((sp) => sp.z !== 0).map((sp) => sp.name);
42
44
  return { role: role(name), slot: charged.filter((n) => role(n) === role(name)).indexOf(name) };
@@ -88,7 +90,7 @@ export function traces(sol, { species, standard = true, phi = false, energy = fa
88
90
  const shown = species ?? names.filter((name) => finite(sol.V[name]) && (!energy || carriers.includes(name)));
89
91
  if (energy) {
90
92
  for (const name of shown) {
91
- if (!carriers.includes(name)) throw new Error(`traces: '${name}' has no band to draw as an energy; draw it as a species voltage (energy: false)`);
93
+ if (!carriers.includes(name)) throw new DeviceError(`traces: '${name}' has no band to draw as an energy; draw it as a species voltage (energy: false)`);
92
94
  }
93
95
  }
94
96
  const sign = energy ? -1 : 1, unit = energy ? 'eV' : 'V';
@@ -98,22 +100,23 @@ export function traces(sol, { species, standard = true, phi = false, energy = fa
98
100
  const pretty = (name) => (name in zOf ? typeset(name, zOf[name]) : name);
99
101
  const series = [];
100
102
  for (const name of shown) {
101
- if (!names.includes(name)) throw new Error(`traces: no charged species '${name}' in the solution`);
103
+ if (!names.includes(name)) throw new DeviceError(`traces: no charged species '${name}' in the solution`);
102
104
  const shift = shifts[name] ?? 0;
103
- const at = (y) => (shift || energy ? Array.from(y, (v) => sign * v + shift) : y);
105
+ const at = (y) => (shift || energy ? Float64Array.from(y, (v) => sign * v + shift) : y);
104
106
  const common = { species: name, role: role(name), slot: slotOf(name), ...(shift ? { shift } : {}) };
105
107
  if (finite(sol.V[name])) series.push({ id: `V:${name}`, label: energy ? energyLabels[`V:${name}`] : `V_{${pretty(name)}}`, kind: 'level', ...common, y: at(sol.V[name]) });
106
108
  if (standard && finite(sol.Vstd[name])) series.push({ id: `Vstd:${name}`, label: energy ? energyLabels[`Vstd:${name}`] : `V°_{${pretty(name)}}`, kind: 'standard', ...common, y: at(sol.Vstd[name]) });
107
109
  }
108
110
  if (Array.isArray(phi)) {
109
111
  const known = sol.regions.flatMap((r) => [r.name, r.material]);
110
- for (const name of phi) if (!known.includes(name)) throw new Error(`traces: no region or material '${name}' to draw φ in (${[...new Set(known)].join(', ')})`);
112
+ for (const name of phi) if (!known.includes(name)) throw new DeviceError(`traces: no region or material '${name}' to draw φ in (${[...new Set(known)].join(', ')})`);
111
113
  const inside = sol.regions.map((r) => phi.includes(r.name) || phi.includes(r.material));
112
- series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: Array.from(sol.phi, (v, g) => (inside[sol.region[g]] ? sign * v : NaN)) });
113
- } else if (phi) series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: energy ? Array.from(sol.phi, (v) => -v) : sol.phi });
114
+ series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: Float64Array.from(sol.phi, (v, g) => (inside[sol.region[g]] ? sign * v : NaN)) });
115
+ } else if (phi) series.push({ id: 'phi', label: energy ? '−qφ' : 'φ', kind: 'phi', role: 'phi', slot: 0, y: energy ? Float64Array.from(sol.phi, (v) => -v) : sol.phi });
116
+ for (const lv of levels) if (typeof lv?.half?.equation !== 'string') throw new DeviceError('traces: each of levels is { half, label, standard }, its half a half() (as { half: half(\'Fe3+ + e- = Fe2+\') })');
114
117
  const couples = [...new Set(levels.map((lv) => lv.half.equation))];
115
118
  levels.forEach((lv, k) => {
116
- const y = energy ? Array.from(level(sol, lv.half, { standard: lv.standard }), (v) => -v) : level(sol, lv.half, { standard: lv.standard });
119
+ const y = energy ? Float64Array.from(level(sol, lv.half, { standard: lv.standard }), (v) => -v) : level(sol, lv.half, { standard: lv.standard });
117
120
  series.push({
118
121
  id: `level:${k}`,
119
122
  label: lv.label ?? `${lv.standard ? 'V° ' : ''}${lv.half.equation.split(' ').map(pretty).join(' ')}`,