driftlet 1.1.0 → 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).
@@ -661,7 +664,7 @@ export class Solver {
661
664
  }
662
665
  this.res = new Float64Array(N + 1);
663
666
  this.delta = new Float64Array(N + 1);
664
- this.rowScale = new Float64Array(N + 1).fill(1); // each row's scaling in the factorised system
667
+ this.rowDiv = new Float64Array(N + 1).fill(1); // what each row is divided by in the factorised system
665
668
  }
666
669
 
667
670
  _factor() {
@@ -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();
@@ -801,6 +804,19 @@ export class Solver {
801
804
  this._laws();
802
805
  }
803
806
 
807
+ // A stretch's amount from its regions' starting concentrations (c0), per unit area, before
808
+ // any state exists: Infinity where one isn't given (set by a level, or doping), to order it last.
809
+ _roughAmount(st) {
810
+ const { regions, grid } = this.model;
811
+ let t = 0;
812
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
813
+ const reg = regions[q], c0 = reg.c0Profile[st.species] ? profileAt(reg.c0Profile[st.species], grid.x[grid.regionStart[q]]) : reg.c0[st.species];
814
+ if (!(c0 >= 0)) return Infinity;
815
+ t += c0 * (grid.x[grid.regionEnd[q]] - grid.x[grid.regionStart[q]]);
816
+ }
817
+ return t;
818
+ }
819
+
804
820
  // Whether a stretch's species moves through it (mobile in every region, or a metal's carrier),
805
821
  // or stays still in every region (D = 0: trap states, conserved node by node).
806
822
  _moves(st) {
@@ -821,6 +837,20 @@ export class Solver {
821
837
  return true;
822
838
  }
823
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
+
824
854
  // Whether terminal k (a contact, or port k − 2) feeds stretch st: links its species there.
825
855
  _feeds(k, st) {
826
856
  return k === 0 ? st.leftOpen : k === 1 ? st.rightOpen : st.linked.includes(k - 2);
@@ -848,6 +878,19 @@ export class Solver {
848
878
  const driven = (st) => this.terms.flatMap((t, k) => (t.drive.kind === 'I' && this._feeds(k, st) ? [k] : []));
849
879
  const add = (parts, rowStretch, surface = [], circuit = -1, carriers = []) => {
850
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) }));
851
894
  let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
852
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;
853
896
  for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
@@ -856,11 +899,11 @@ export class Solver {
856
899
  parts,
857
900
  surface,
858
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)
859
903
  terminals: [...new Set([...parts.flatMap((p) => driven(this.stretches[p.stretch])), ...carriers.map((p) => p.k)])],
860
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(),
861
905
  circuit, // (the terminal whose circuit row it stands in for, or −1 for a balance row's, `row`)
862
- // (and the terminal's weight in it, w/z on what that feeds: the amount changes by λ I/F)
863
- 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,
864
907
  row: circuit >= 0 ? -1 : this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
865
908
  idx: new Int32Array(nNodes * this.M),
866
909
  w: new Float64Array(nNodes * this.M),
@@ -877,7 +920,12 @@ export class Solver {
877
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);
878
921
  });
879
922
  const S = this.stretches.length, T = this.terms.length, X = S + this.surfCols.length;
880
- 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);
881
929
  // The rows replaced: a moving stretch's balance (one of still stretches alone is kept node by
882
930
  // node, below), or the circuit row of a terminal driven at no current, which says nothing the
883
931
  // combination's balance rows don't (their weighted sum is that terminal's current, weighted
@@ -927,11 +975,16 @@ export class Solver {
927
975
  // A current held at zero passes nothing: an electrode at open circuit feeding a closed
928
976
  // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
929
977
  const passes = (k) => !this._noCurrent(this.terms[k]);
930
- 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))));
931
979
  // Any of the combinations it alone changes will do, with any conserved amount added (whose
932
980
  // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
933
981
  // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
934
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;
935
988
  const kept = laws.filter((l) => !fed(l));
936
989
  const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
937
990
  for (const w of tries) {
@@ -963,6 +1016,7 @@ export class Solver {
963
1016
  break;
964
1017
  }
965
1018
  }
1019
+ this.contactQ = [null, null];
966
1020
  for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
967
1021
  for (const [j, k] of this.carriers.entries()) {
968
1022
  const c = held.get(k);
@@ -971,10 +1025,13 @@ export class Solver {
971
1025
  w = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
972
1026
  w.own = own;
973
1027
  }
974
- const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
975
- 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] }));
976
1030
  const carriers = this.carriers.flatMap((k, j) => (w[X + j] !== 0 ? [{ k, w: w[X + j] }] : []));
977
- 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;
978
1035
  if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
979
1036
  if (parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
980
1037
  const v = new Float64Array(S + T);
@@ -992,11 +1049,14 @@ export class Solver {
992
1049
  if (!(scale > 1e-9)) continue;
993
1050
  const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
994
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);
995
- 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.)
996
1055
  chosen.push([col, v.map((x) => x / v[col])]);
997
- if (col >= S) held.set(col - S, w);
998
- 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);
999
1058
  }
1059
+ this.contactLaw = [0, 1].map((k) => this.constraints.some((cs) => cs.contacts.some((c) => c.t === k)));
1000
1060
  // A combination that nothing carries anywhere is conserved node by node: of still stretches
1001
1061
  // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
1002
1062
  // that only turn into each other). At each node its weighted sum stays what it was, a row local
@@ -1005,18 +1065,47 @@ export class Solver {
1005
1065
  const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
1006
1066
  for (const w of laws) {
1007
1067
  const parts = [...w.keys()].filter((k) => w[k] !== 0);
1008
- if (parts.some((k) => k >= X)) continue; // (through an electrode: above)
1009
- 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));
1010
- if (sets.some((set) => set === null || set.length !== sets[0].length || set.some((g, j) => g !== sets[0][j]))) continue; // (side by side only)
1011
- for (const k of parts) if (k < S) this.stretches[k].conserved = true;
1012
- 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];
1013
1082
  this.localConstraints.push({
1014
- parts: parts.map((k) => (k < S ? { i: this.stretches[k].species, w: w[k] } : { q: this.surfCols[k - S][1], w: w[k] })),
1015
- row: slot(parts.includes(w.own) ? w.own : parts[0]),
1016
- 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,
1017
1086
  reference: new Float64Array(sets[0].length),
1018
1087
  });
1019
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
+ }
1020
1109
  // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
1021
1110
  // contact, port or reaction) holds its level only through its faces' fluxes, which can be
1022
1111
  // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
@@ -1095,7 +1184,7 @@ export class Solver {
1095
1184
  if (fedBy.size !== 1) continue;
1096
1185
  const [k] = fedBy;
1097
1186
  if (!behind.includes(k) || this.timeLaws.some((cs) => cs.circuit === k)) continue;
1098
- const zc = k >= 2 ? this.z[model.ports[k - 2].terminal] : 0;
1187
+ const zc = k >= 2 ? this._carrierZ(k) : 0;
1099
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);
1100
1189
  if (!lambda) continue;
1101
1190
  let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
@@ -1118,13 +1207,14 @@ export class Solver {
1118
1207
  for (const k of charged) this.chargeRefs.set(k, k >= 2 ? this.portQ[k - 2] : k === 0 ? this.contactD.left : -this.contactD.right);
1119
1208
  }
1120
1209
  this.lawRefs.clear();
1121
- 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);
1122
1211
  for (const cs of this.constraints) {
1123
1212
  if (cs.terminals.length === 0) continue;
1124
1213
  let a = 0;
1125
1214
  for (const { stretch, w } of cs.parts) a += w * this.amount(this.stretches[stretch]);
1126
1215
  for (const { col, w } of cs.surface) a += w * this.surfaceAmount(col);
1127
- 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;
1128
1218
  this.lawRefs.set(cs.key, a);
1129
1219
  }
1130
1220
  }
@@ -1136,7 +1226,10 @@ export class Solver {
1136
1226
  for (const lc of this.localConstraints) {
1137
1227
  lc.nodes.forEach((g, j) => {
1138
1228
  let t = 0;
1139
- 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
+ }
1140
1233
  lc.reference[j] = t;
1141
1234
  });
1142
1235
  }
@@ -1207,6 +1300,14 @@ export class Solver {
1207
1300
  }
1208
1301
  }
1209
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
+
1210
1311
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
1211
1312
  _applyLocalConstraints() {
1212
1313
  const { n, M, c, th, nSurf, res, dA: d } = this;
@@ -1216,17 +1317,18 @@ export class Solver {
1216
1317
  if (this.loc[b * M + lc.row] < 0) return;
1217
1318
  this._replaceRow(b, lc.row);
1218
1319
  let t = 0;
1219
- for (const { i, q, w } of lc.parts) {
1320
+ for (const { i, q, k, w } of lc.parts) {
1220
1321
  if (i !== undefined) {
1221
1322
  t += w * c[g * n + i];
1222
1323
  this._dc(g, i, d);
1223
1324
  for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1224
1325
  continue;
1225
1326
  }
1226
- // A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species.
1227
- const tq = th[g * nSurf + q], ns = this.model.ports[this.surfPort[g]].surface.length;
1228
- t += w * tq;
1229
- 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]));
1230
1332
  }
1231
1333
  res[this.rix[b * M + lc.row]] = t - lc.reference[j];
1232
1334
  });
@@ -1248,8 +1350,12 @@ export class Solver {
1248
1350
  // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
1249
1351
  // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
1250
1352
  const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
1251
- 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] : [])) : [];
1252
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;
1253
1359
  // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1254
1360
  // its own rows, over the columns, are handed back as `basis.skipped`.)
1255
1361
  const rows = [], skipped = [];
@@ -1293,7 +1399,7 @@ export class Solver {
1293
1399
  const fed = this.stretches.flatMap((st, j) => (this._feeds(k, st) ? [j] : []));
1294
1400
  if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
1295
1401
  const charged = fed.filter((j) => z[this.stretches[j].species] !== 0).map((j) => [j, z[this.stretches[j].species]]);
1296
- 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)]);
1297
1403
  fed.filter((j) => z[this.stretches[j].species] === 0).forEach(unit);
1298
1404
  for (let a = 1; a < charged.length; a++) {
1299
1405
  const row = new Float64Array(C);
@@ -1303,9 +1409,22 @@ export class Solver {
1303
1409
  }
1304
1410
  });
1305
1411
  // Reduced row echelon form; the free columns give the null space. Immobile stretches' columns
1306
- // go first, so that a combination of them alone (trap states, X⁰ + X⁻) comes out on its own,
1307
- // to be kept node by node, apart from any that moves (e⁻ − X⁰, the electrons the traps hold).
1308
- const order = [...Array(C).keys()].sort((a, b) => (b < S && this._still(this.stretches[b])) - (a < S && this._still(this.stretches[a])));
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.)
1416
+ // Then the stretches by their starting amounts, least first, and by name: a trace species
1417
+ // pivoted after an abundant one comes out added to it (A + B ⇌ C with B at 1 M and A at 1e-14
1418
+ // gave B + C and A − B, where A + C was lost to round-off), and the basis followed the listing.
1419
+ const still = (k) => ((isStretch(k) && this._still(this.stretches[k])) || isSurface(k) ? 1 : 0);
1420
+ const rough = this.stretches.map((st) => this._roughAmount(st)), name = (k) => model.species[this.stretches[k].species].name;
1421
+ const order = [...Array(C).keys()].sort((a, b) => {
1422
+ if (still(a) !== still(b)) return still(b) - still(a);
1423
+ if (!isStretch(a) || !isStretch(b)) return a - b;
1424
+ if (rough[a] !== rough[b]) return rough[a] - rough[b];
1425
+ const na = name(a), nb = name(b);
1426
+ return na < nb ? -1 : na > nb ? 1 : a - b;
1427
+ });
1309
1428
  const pivots = [];
1310
1429
  let r = 0;
1311
1430
  for (const col of order) {
@@ -1362,8 +1481,16 @@ export class Solver {
1362
1481
  const C = this.termC[cs.circuit];
1363
1482
  C.fill(0);
1364
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
+ }
1365
1491
  this.termRes[cs.circuit] = cs.res;
1366
- 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));
1367
1494
  continue;
1368
1495
  }
1369
1496
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
@@ -1421,24 +1548,25 @@ export class Solver {
1421
1548
  }
1422
1549
  // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
1423
1550
  // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as _portCapacitance has it).
1424
- let dV = 0;
1551
+ const dV = cs.dV ?? (cs.dV = new Float64Array(this.terms.length));
1552
+ dV.fill(0);
1425
1553
  for (const { k, w } of cs.carriers) {
1426
1554
  const port = this.model.ports[k - 2];
1427
1555
  if (!port.capacitance) continue;
1428
- 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];
1429
1557
  const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
1430
1558
  port.nodes.forEach((g, j) => {
1431
1559
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
1432
1560
  const b = this.blockOfNode[g], s = vol[g] * this.portArea[k - 2][j] * Cs;
1433
1561
  amount += f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M]));
1434
1562
  gross += Math.abs(f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M])));
1435
- dV += f * s;
1563
+ dV[k] += f * s;
1436
1564
  cs.idx[len] = this.rix[b * M];
1437
1565
  cs.w[len++] = -f * s * this.VT;
1438
1566
  });
1439
1567
  }
1440
1568
  cs.len = len;
1441
- return { amount, reference, dV, gross };
1569
+ return { amount, reference, dV, gross }; // (dV by terminal)
1442
1570
  }
1443
1571
 
1444
1572
  // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
@@ -1453,7 +1581,7 @@ export class Solver {
1453
1581
  const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity;
1454
1582
  port.nodes.forEach((g, j) => (a += w * G * vol[g] * this.portArea[k][j] * this.thOld[g * nSurf + q]));
1455
1583
  }
1456
- 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);
1457
1585
  return a;
1458
1586
  }
1459
1587
 
@@ -1473,7 +1601,7 @@ export class Solver {
1473
1601
  _circuitLaws(dt) {
1474
1602
  for (const cs of [...this.constraints, ...this.timeLaws]) {
1475
1603
  const k = cs.circuit;
1476
- 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)
1477
1605
  const { amount, dV, gross } = this._lawAmount(cs), f = FARADAY / (cs.lambda * dt), C = this.termC[k];
1478
1606
  if (!this.lawForm.has(cs)) {
1479
1607
  let G = 0;
@@ -1485,7 +1613,8 @@ export class Solver {
1485
1613
  for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
1486
1614
  const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1487
1615
  this.termRes[k] = f * (amount - this._lawStart(cs)) - (d.kind === 'I' ? src : (src - this.termV[k]) / d.R);
1488
- 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));
1489
1618
  }
1490
1619
  }
1491
1620
 
@@ -1529,7 +1658,10 @@ export class Solver {
1529
1658
  const A = Array.from({ length: S }, () => new Float64Array(S + 1));
1530
1659
  for (let a = 0; a < S; a++) {
1531
1660
  for (let b = 0; b < S; b++) A[a][b] = (b < P ? 1 : -1) * dot(a, cols[b]);
1532
- 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
+ }
1533
1665
  A[a][S] = (a < P ? pins[a].res : this.termRes[fl[a - P]]) - dot(a, delta);
1534
1666
  let mx = 0;
1535
1667
  for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
@@ -2270,10 +2402,23 @@ export class Solver {
2270
2402
  return;
2271
2403
  }
2272
2404
  let q = this.rhoFixed[g], dq = 0;
2405
+ const lin = this.lin;
2273
2406
  for (let i = 0; i < n; i++) {
2274
2407
  const k = g * n + i, r = 1 + i;
2275
2408
  if (!this.present[k]) continue;
2276
2409
  const Kz = this._Kz(g, i);
2410
+ if (lin) {
2411
+ // In difference form (see DifferenceTerms): storage of i and its charge, in each
2412
+ // δη_j − z_j δφ̂ through K_ij.
2413
+ res[R[b * M + r]] += (v * (c[k] - this.cOld[k])) / dt;
2414
+ q += F * z[i] * c[k];
2415
+ const Kg = g * n * n + i * n;
2416
+ for (let j = 0; j < n; j++) {
2417
+ const Kij = this.nodeIdeal[g] ? (i === j ? c[k] : 0) : this.K[Kg + j];
2418
+ if (Kij !== 0) lin.node(R[b * M + 1 + j], R[b * M], z[j], v * Kij, -v * F * z[i] * Kij, R[b * M + r]);
2419
+ }
2420
+ continue;
2421
+ }
2277
2422
  this._storage(g, i, dt, Kz);
2278
2423
  q += F * z[i] * c[k];
2279
2424
  dq += F * z[i] * Kz;
@@ -2282,7 +2427,7 @@ export class Solver {
2282
2427
  // (where no charge responds and no field reaches, φ isn't defined, nor an unknown)
2283
2428
  if (!this.phiUndefined[g]) {
2284
2429
  res[R[b * M]] -= v * q;
2285
- this._j(b, 0, b, 0, v * dq);
2430
+ if (!lin) this._j(b, 0, b, 0, v * dq);
2286
2431
  }
2287
2432
  }
2288
2433
 
@@ -2420,7 +2565,7 @@ export class Solver {
2420
2565
  aHi += rx.nu[p] * u[b * M + 1 + rx.sp[p]];
2421
2566
  aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
2422
2567
  }
2423
- const { rate, Cn, Cp, Ca } = this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo);
2568
+ const { rate, Cn, Cp, Ca } = this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo, ...this._srhDegen(g, g, law));
2424
2569
  dr.fill(0);
2425
2570
  this._dlnc(g, law.n.i, Cn, dr);
2426
2571
  this._dlnc(g, law.p.i, Cp, dr);
@@ -2690,39 +2835,28 @@ export class Solver {
2690
2835
  // still exactly zero at equilibrium. c_L and ex depend on every ζ at their node through K.
2691
2836
  _segmentConcentrated(s, bL, bR, mat, h, vel) {
2692
2837
  const R = this.rix;
2693
- const { n, M, u, uLo, res, c, z, K, ex, jL, jR } = this;
2694
- const gL = s, gR = s + 1, KL = gL * n * n, KR = gR * n * n;
2838
+ const { n, M, res, z, K } = this;
2839
+ const KL = s * n * n, KR = (s + 1) * n * n;
2695
2840
  for (let i = 0; i < n; i++) {
2696
2841
  if (!mat.present[i] || mat.D[i] === 0) continue;
2697
2842
  const r = 1 + i, zi = z[i];
2698
- const cL = c[gL * n + i], cR = c[gR * n + i];
2699
- const g = mat.D[i] / h;
2700
- const pe = (vel * h) / mat.D[i];
2701
- const d = zi * (u[bR * M] - u[bL * M]) + ex[gR * n + i] - ex[gL * n + i] - pe;
2702
- const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
2703
- const E = Math.expm1(deta);
2704
- const B = bernoulli(d), Bp = bernoulliDerivative(d);
2705
- const gBc = g * B * cL;
2706
- const N = -gBc * E;
2707
- const dNdd = -g * Bp * cL * E;
2708
- // Left node: ∂N/∂ζ_Lj = −gE(B + B′)K_L,ij − dNdd·δ_ij, plus the direct η_L,i term.
2709
- const fL = -g * E * (B + Bp);
2710
- jL[0] = -fL * this._Kz(gL, i);
2711
- for (let j = 0; j < n; j++) jL[1 + j] = fL * K[KL + i * n + j];
2712
- jL[r] += -dNdd + gBc * (E + 1);
2713
- // Right node: ∂N/∂ζ_Rj = dNdd·(δ_ij − K_R,ij/c_R), plus the direct η_R,i term.
2714
- jR.fill(0);
2715
- if (cR > 0) {
2716
- const fR = -dNdd / cR;
2717
- jR[0] = -fR * this._Kz(gR, i);
2718
- for (let j = 0; j < n; j++) jR[1 + j] = fR * K[KR + i * n + j];
2719
- } else {
2720
- jR[0] = dNdd * zi; // K/c → δ as c → 0
2721
- jR[r] -= dNdd;
2722
- }
2723
- jR[r] += dNdd - gBc * (E + 1);
2843
+ const { N, E, gBc, dNdd, fL, cR } = this._fluxConcentrated(s, bL, bR, mat, h, vel, i);
2724
2844
  res[R[bL * M + r]] += N;
2725
2845
  res[R[bR * M + r]] -= N;
2846
+ if (this.lin) {
2847
+ // In difference form (see DifferenceTerms): b on Δη_i and z_i·∂N/∂d on Δφ̂, and the rest
2848
+ // as side terms in each δη_j − z_j δφ̂ at either end (c_L and the excess potentials).
2849
+ const rL = R[bL * M + r], rR = R[bR * M + r], pL = R[bL * M], pR = R[bR * M];
2850
+ this.lin.segment(rL, rR, pL, pR, zi, 0, -gBc * (E + 1), zi * dNdd);
2851
+ for (let j = 0; j < n; j++) {
2852
+ const wL = fL * K[KL + i * n + j] - (j === i ? dNdd : 0);
2853
+ const wR = cR > 0 ? dNdd * ((j === i ? 1 : 0) - K[KR + i * n + j] / cR) : 0;
2854
+ if (wL !== 0) this.lin.side(rL, rR, R[bL * M + 1 + j], pL, z[j], wL);
2855
+ if (wR !== 0) this.lin.side(rL, rR, R[bR * M + 1 + j], pR, z[j], wR);
2856
+ }
2857
+ continue;
2858
+ }
2859
+ const { jL, jR } = this;
2726
2860
  for (let k = 0; k < M; k++) {
2727
2861
  if (jL[k] !== 0) {
2728
2862
  this._j(bL, r, bL, k, jL[k]);
@@ -2736,6 +2870,40 @@ export class Solver {
2736
2870
  }
2737
2871
  }
2738
2872
 
2873
+ // Species i's flux across segment s of a non-ideal region, toward +x, and its derivatives in
2874
+ // this.jL and this.jR (over the unknowns of the blocks bL and bR).
2875
+ _fluxConcentrated(s, bL, bR, mat, h, vel, i) {
2876
+ const { n, M, u, uLo, c, z, K, ex, jL, jR } = this;
2877
+ const gL = s, gR = s + 1, KL = gL * n * n, KR = gR * n * n, r = 1 + i, zi = z[i];
2878
+ const cL = c[gL * n + i], cR = c[gR * n + i];
2879
+ const g = mat.D[i] / h;
2880
+ const pe = (vel * h) / mat.D[i];
2881
+ const d = zi * (u[bR * M] - u[bL * M]) + ex[gR * n + i] - ex[gL * n + i] - pe;
2882
+ const deta = u[bR * M + r] - u[bL * M + r] + (uLo[bR * M + r] - uLo[bL * M + r]) - pe;
2883
+ const E = Math.expm1(deta);
2884
+ const B = bernoulli(d), Bp = bernoulliDerivative(d);
2885
+ const gBc = g * B * cL;
2886
+ const N = -gBc * E;
2887
+ const dNdd = -g * Bp * cL * E;
2888
+ // Left node: ∂N/∂ζ_Lj = −gE(B + B′)K_L,ij − dNdd·δ_ij, plus the direct η_L,i term.
2889
+ const fL = -g * E * (B + Bp);
2890
+ jL[0] = -fL * this._Kz(gL, i);
2891
+ for (let j = 0; j < n; j++) jL[1 + j] = fL * K[KL + i * n + j];
2892
+ jL[r] += -dNdd + gBc * (E + 1);
2893
+ // Right node: ∂N/∂ζ_Rj = dNdd·(δ_ij − K_R,ij/c_R), plus the direct η_R,i term.
2894
+ jR.fill(0);
2895
+ if (cR > 0) {
2896
+ const fR = -dNdd / cR;
2897
+ jR[0] = -fR * this._Kz(gR, i);
2898
+ for (let j = 0; j < n; j++) jR[1 + j] = fR * K[KR + i * n + j];
2899
+ } else {
2900
+ jR[0] = dNdd * zi; // K/c → δ as c → 0
2901
+ jR[r] -= dNdd;
2902
+ }
2903
+ jR[r] += dNdd - gBc * (E + 1);
2904
+ return { N, E, gBc, dNdd, fL, cR };
2905
+ }
2906
+
2739
2907
  // Ohmic conduction in a metal, in mixed form. The flux J (slot 0 of the left node) obeys
2740
2908
  // η_R − η_L + J/g = 0, g = σ RT/(z²F² h) (Ohm's law, J = −g Δη),
2741
2909
  // and enters the two nodes' carrier balances as outflow and inflow.
@@ -2856,7 +3024,7 @@ export class Solver {
2856
3024
  aLo += rx.nu[p] * uLo[b * M + 1 + rx.sp[p]];
2857
3025
  }
2858
3026
  const law = rx.srh?.[this.nodeMaterial[g]];
2859
- if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo).rate;
3027
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], aHi + aLo, ...this._srhDegen(g, g, law)).rate;
2860
3028
  let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2861
3029
  for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2862
3030
  return P * -Math.expm1(-(aHi + aLo));
@@ -2870,7 +3038,7 @@ export class Solver {
2870
3038
  if (law) {
2871
3039
  let a = rx.fixedA;
2872
3040
  for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
2873
- return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).forward;
3041
+ return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a, ...this._srhDegen(g, g, law)).forward;
2874
3042
  }
2875
3043
  let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2876
3044
  for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
@@ -2884,7 +3052,7 @@ export class Solver {
2884
3052
  let a = rx.fixedA;
2885
3053
  for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
2886
3054
  const law = rx.srh?.[this.nodeMaterial[g]];
2887
- if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).oneWay;
3055
+ if (law) return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a, ...this._srhDegen(g, g, law)).oneWay;
2888
3056
  let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2889
3057
  for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2890
3058
  return P * Math.max(1, Math.exp(-a));
@@ -2892,21 +3060,46 @@ export class Solver {
2892
3060
 
2893
3061
  // SRH kinetics (see srhLaw in device.js): the rate, and its sensitivities to ln n, ln p and
2894
3062
  // a = A/RT. With no n₁ given, the trap is midgap: n₁ = p₁ = n_i = √(n p e^{−a}).
2895
- _srhRate(law, cn, cp, a) {
3063
+ // SRH at electron and hole concentrations cn, cp and affinity a. n₁p₁ = n p e^{−a} (n_i² for
3064
+ // Boltzmann carriers), and under degeneracy each carrier's emission scales with its own
3065
+ // γ = e^{−ex} (its excess chemical potential ex, c/K = ∂ln c/∂ζ its slope): n₁ = γ_n n₁₀ and
3066
+ // p₁ = γ_p p₁₀, n₁₀ the law's n1, or n_i at midgap. (γ_n γ_p split evenly onto both, as it was,
3067
+ // put a degenerate n⁺ region's γ_n on its holes' emission too.) The rate's derivatives in ln n,
3068
+ // ln p and a. For large negative a (strong extraction), np·(1 − e^{−a}) as −n p e^{−a}.
3069
+ _srhRate(law, cn, cp, a, xn = 0, xp = 0, sn = 1, sp = 1) {
2896
3070
  const { tn, tp } = law, lnnp = Math.log(cn) + Math.log(cp);
2897
- let n1, kN = 0, kP = 0, kA = 0; // ln n₁ = kN ln n + kP ln p + kA a + const
3071
+ let lnn1, kN = 0, kP = 0, kA = 0; // ln n₁ = kN ln n + kP ln p + kA a + const
2898
3072
  if (Number.isNaN(law.n1)) {
2899
- n1 = Math.exp((lnnp - a) / 2);
2900
- kN = kP = 0.5;
3073
+ lnn1 = (lnnp - a - xn + xp) / 2; // γ_n n_i, with n_i² = n p e^{−a}/(γ_n γ_p)
3074
+ kN = (2 - sn) / 2;
3075
+ kP = sp / 2;
2901
3076
  kA = -0.5;
2902
- } else n1 = law.n1;
2903
- const p1 = Math.exp(lnnp - a - Math.log(n1)), np = cn * cp;
2904
- const den = tp * (cn + n1) + tn * (cp + p1), f = -Math.expm1(-a);
2905
- const rate = (np * f) / den;
3077
+ } else {
3078
+ lnn1 = Math.log(law.n1) - xn;
3079
+ kN = 1 - sn;
3080
+ }
3081
+ const n1 = Math.exp(lnn1), p1 = Math.exp(lnnp - a - lnn1), np = cn * cp;
3082
+ const den = tp * (cn + n1) + tn * (cp + p1);
3083
+ // np(1 − e^{−a}), with e^{−a} folded into np where it would overflow
3084
+ const f = -Math.expm1(-a), num = a > -700 ? np * f : np - Math.exp(lnnp - a);
3085
+ const rate = num / den;
2906
3086
  const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
2907
3087
  const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
2908
3088
  const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
2909
- return { rate, forward: np / den, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
3089
+ return { rate, forward: np / den, oneWay: a > -700 ? (np / den) * Math.max(1, Math.exp(-a)) : Math.exp(lnnp - a) / den, Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (a > -700 ? (np / den) * (1 - f) : Math.exp(lnnp - a) / den) - (rate * dDa) / den };
3090
+ }
3091
+
3092
+ // _srhRate's degeneracy arguments: the electrons' at node gn, the holes' at gq.
3093
+ _srhDegen(gn, gq, law) {
3094
+ const [xn, sn] = this._degen(gn, law.n.i), [xp, sp] = this._degen(gq, law.p.i);
3095
+ return [xn, xp, sn, sp];
3096
+ }
3097
+
3098
+ // A carrier's excess chemical potential at node g and ∂ln c/∂ζ there (0 and 1 if ideal).
3099
+ _degen(g, i) {
3100
+ if (this.nodeIdeal[g]) return [0, 1];
3101
+ const n = this.n, c = this.c[g * n + i], K = this.K[g * n * n + i * n + i];
3102
+ return [this.ex[g * n + i], c > 0 && K > 0 ? c / K : 1];
2910
3103
  }
2911
3104
 
2912
3105
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
@@ -2949,7 +3142,7 @@ export class Solver {
2949
3142
  } else if (rx.srh) {
2950
3143
  // SRH, with n and p each at its own side's edge node.
2951
3144
  const { n: pn, p: pp } = rx.srh, gn = pn.side ? gR : gL, gq = pp.side ? gR : gL;
2952
- const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo);
3145
+ const s = this._srhRate(rx.srh, c[gn * n + pn.i], c[gq * n + pp.i], aHi + aLo, ...this._srhDegen(gn, gq, { n: pn, p: pp }));
2953
3146
  rate = s.rate;
2954
3147
  this._dlnc(gn, pn.i, s.Cn, pn.side ? dR : dL);
2955
3148
  this._dlnc(gq, pp.i, s.Cp, pp.side ? dR : dL);
@@ -2997,6 +3190,7 @@ export class Solver {
2997
3190
  this.termC[k].fill(0);
2998
3191
  }
2999
3192
  this.termDI.fill(0);
3193
+ for (const x of this.termDX) x.fill(0);
3000
3194
  this.termI.fill(0);
3001
3195
  this.qg.fill(0);
3002
3196
  this.qgSlope.fill(0);
@@ -3049,7 +3243,7 @@ export class Solver {
3049
3243
  const r = 1 + i, target = this.portEta(port, i), zF = z[i] * F;
3050
3244
  for (const g of port.nodes) {
3051
3245
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
3052
- const deta = target - (u[o] + uLo[o]); // (μ̄_out − μ̄)/RT
3246
+ const deta = target - u[o] - uLo[o]; // (μ̄_out − μ̄)/RT
3053
3247
  if (link.type === 'equilibrium') {
3054
3248
  // a device end node's level is its contact's business, where the contact links it
3055
3249
  if ((g === 0 && this.model.contacts.left.species[i].type !== 'blocked') || (g === this.nNodes - 1 && this.model.contacts.right.species[i].type !== 'blocked')) continue;
@@ -3294,7 +3488,7 @@ export class Solver {
3294
3488
  const link = ct.species[i];
3295
3489
  const o = b * M + 1 + i;
3296
3490
  if (link.type === 'exchange') {
3297
- res[R[o]] -= Ac * link.k * (link.mu / model.RT - (u[o] + uLo[o]));
3491
+ res[R[o]] -= Ac * link.k * (link.mu / model.RT - u[o] - uLo[o]);
3298
3492
  this._j(b, 1 + i, b, 1 + i, Ac * link.k);
3299
3493
  } else if (link.type === 'conductance') {
3300
3494
  const Vi = (VT * (u[o] + uLo[o])) / z[i];
@@ -3303,7 +3497,7 @@ export class Solver {
3303
3497
  B[R[o]] += (-Ac * link.G) / (z[i] * F);
3304
3498
  } else if (link.type === 'velocity') {
3305
3499
  // N_in = v c (e^(η_out − η) − 1): v (c_eq − c), c_eq in equilibrium with the outside.
3306
- const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - (u[o] + uLo[o]), 700));
3500
+ const ci = c[g * n + i], E = Math.exp(Math.min(this.contactEta(side, i) - u[o] - uLo[o], 700));
3307
3501
  const N = Ac * link.v * ci * (E - 1), d = this.dA;
3308
3502
  res[R[o]] -= N;
3309
3503
  d.fill(0);
@@ -3321,13 +3515,20 @@ export class Solver {
3321
3515
  const o = b * M + 1 + i;
3322
3516
  this._replaceRow(b, 1 + i);
3323
3517
  this._j(b, 1 + i, b, 1 + i, 1);
3324
- res[R[o]] = u[o] + uLo[o] - this.contactEta(side, i);
3518
+ res[R[o]] = u[o] - this.contactEta(side, i) + uLo[o]; // (the big terms first: u + uLo rounds to u)
3325
3519
  if (z[i] !== 0) B[R[o]] += -z[i] / VT;
3326
3520
  }
3327
3521
 
3328
3522
  // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
3329
3523
  // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
3330
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
+ }
3331
3532
  if (this.phiUndefined[g]) {
3332
3533
  this.contactD[side] = 0;
3333
3534
  } else if (link.type === 'capacitive') {
@@ -3337,6 +3538,7 @@ export class Solver {
3337
3538
  this._j(b, 0, b, 0, Ac * link.C * VT);
3338
3539
  B[R[b * M]] += -Ac * link.C;
3339
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];
3340
3542
  // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
3341
3543
  // the charge it keeps in place of that (see _captureLaws).
3342
3544
  const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
@@ -3349,6 +3551,10 @@ export class Solver {
3349
3551
  // The Poisson residual is the outside's charge, D_in.
3350
3552
  const Din = res[R[b * M]];
3351
3553
  this.contactD[side] = sgn * Din;
3554
+ if (q) {
3555
+ q.D = Din;
3556
+ this._captureRow(b, 0, 1, q.row);
3557
+ }
3352
3558
  if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
3353
3559
  I += (Din - Dstart) / dt;
3354
3560
  this._captureRow(b, 0, 1 / dt, C);
@@ -3360,7 +3566,7 @@ export class Solver {
3360
3566
  if (link.type === 'pinned') {
3361
3567
  // φ_edge = V_t − zeroCharge (the C → ∞ limit).
3362
3568
  this._j(b, 0, b, 0, 1);
3363
- res[R[b * M]] = u[b * M] + uLo[b * M] - (Vt - link.zeroCharge) / VT;
3569
+ res[R[b * M]] = u[b * M] - (Vt - link.zeroCharge) / VT + uLo[b * M];
3364
3570
  B[R[b * M]] += -1 / VT;
3365
3571
  } else {
3366
3572
  // Plain bulk: local neutrality.
@@ -3422,14 +3628,14 @@ export class Solver {
3422
3628
  const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
3423
3629
  for (const st of this.flatStretches) {
3424
3630
  const i = st.species, open = st.leftOpen || st.rightOpen;
3425
- const { k, hi, lo } = this._flatLevel(st), level = hi + lo;
3631
+ const { k, hi, lo } = this._flatLevel(st);
3426
3632
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
3427
3633
  const b = this.blockOfNode[g], o = b * M + 1 + i;
3428
3634
  if (this.loc[o] < 0) continue;
3429
3635
  this._replaceRow(b, 1 + i);
3430
3636
  for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
3431
3637
  this._j(b, 1 + i, b, 1 + i, 1);
3432
- res[R[o]] = u[o] + uLo[o] - level;
3638
+ res[R[o]] = u[o] - hi + (uLo[o] - lo); // (the big terms first: hi + lo rounds to hi)
3433
3639
  if (open && z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
3434
3640
  }
3435
3641
  // A face inside the stretch whose link holds the level continuous carries the species' flux
@@ -3482,7 +3688,8 @@ export class Solver {
3482
3688
  if (b < nB - 1) sys.C.fill(0, sys.offC[b] + l * sz[b + 1], sys.offC[b] + (l + 1) * sz[b + 1]);
3483
3689
  }
3484
3690
 
3485
- // Scale every row by its largest Jacobian entry (in place, residual too).
3691
+ // Scale every row by its largest Jacobian entry (in place, residual too), dividing by it: its
3692
+ // reciprocal overflows where it's subnormal (a co-ion crowded out of a double layer, ~1e-310).
3486
3693
  _equilibrate() {
3487
3694
  const { nB, sys, res, termB } = this, live = this._liveTerms();
3488
3695
  const { A, B, C, sizes, offA, offB, offC, offX } = sys;
@@ -3495,16 +3702,16 @@ export class Solver {
3495
3702
  for (let k = 0; k < m; k++) mx = Math.max(mx, Math.abs(B[ob + k]));
3496
3703
  for (let k = 0; k < mn; k++) mx = Math.max(mx, Math.abs(C[oc + k]));
3497
3704
  if (mx === 0) {
3498
- this.rowScale[offX[b] + r] = 1;
3705
+ this.rowDiv[offX[b] + r] = 1;
3499
3706
  continue;
3500
3707
  }
3501
- const s = 1 / mx;
3502
- this.rowScale[offX[b] + r] = s;
3503
- for (let k = 0; k < mp; k++) A[oa + k] *= s;
3504
- for (let k = 0; k < m; k++) B[ob + k] *= s;
3505
- for (let k = 0; k < mn; k++) C[oc + k] *= s;
3506
- res[offX[b] + r] *= s;
3507
- for (const k of live) termB[k][offX[b] + r] *= s; // (each terminal's ∂/∂V)
3708
+ const d = mx;
3709
+ this.rowDiv[offX[b] + r] = d;
3710
+ for (let k = 0; k < mp; k++) A[oa + k] /= d;
3711
+ for (let k = 0; k < m; k++) B[ob + k] /= d;
3712
+ for (let k = 0; k < mn; k++) C[oc + k] /= d;
3713
+ res[offX[b] + r] /= d;
3714
+ for (const k of live) termB[k][offX[b] + r] /= d; // (each terminal's ∂/∂V)
3508
3715
  }
3509
3716
  }
3510
3717
  }
@@ -3515,7 +3722,8 @@ export class Solver {
3515
3722
  _assembleDifference(dt) {
3516
3723
  const keep = this.sys;
3517
3724
  const rest = this.sysRest ?? (this.sysRest = new BlockTridiagonal(keep.n, keep.sizes));
3518
- const lin = this.diffTerms ?? (this.diffTerms = new DifferenceTerms(keep.size, this.nNodes * this.n, this.nNodes * this.n));
3725
+ const n = this.n, nN = this.nNodes;
3726
+ const lin = this.diffTerms ?? (this.diffTerms = new DifferenceTerms(keep.size, nN * n * n, nN * n, 2 * nN * n * n));
3519
3727
  this.sys = rest;
3520
3728
  this.lin = lin;
3521
3729
  try {
@@ -3536,15 +3744,16 @@ export class Solver {
3536
3744
  // solve's and leave refined (in the transformed unknowns, as _solveBordered gives them); false
3537
3745
  // if refining didn't help, leaving them as they came.
3538
3746
  _refine(dt, delta, deltaV) {
3539
- const { res, termRes, termB, termC, termDI, rowScale, floating: fl } = this;
3747
+ const { res, termRes, termB, termC, termDI, rowDiv, floating: fl } = this;
3540
3748
  const N = this.sys.size, K = fl.length, pins = this.pins, R = this.rix;
3541
- 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) };
3542
3750
  const restoreArrays = () => {
3543
3751
  res.set(save.res);
3544
3752
  termRes.set(save.termRes);
3545
3753
  save.termB.forEach((b, k) => termB[k].set(b));
3546
3754
  save.termC.forEach((c, k) => termC[k].set(c));
3547
3755
  termDI.set(save.termDI);
3756
+ save.termDX.forEach((x, k) => this.termDX[k].set(x));
3548
3757
  this.transformed = save.transformed;
3549
3758
  this.pins.forEach((p, a) => (p.res = save.pinRes[a]));
3550
3759
  };
@@ -3568,10 +3777,11 @@ export class Solver {
3568
3777
  for (let j = 0; j < N; j++) {
3569
3778
  let t = jv[j];
3570
3779
  for (let a = 0; a < K; a++) t += Bk[a][j] * x[N + a];
3571
- out[j] = t * rowScale[j];
3780
+ out[j] = t / rowDiv[j];
3572
3781
  }
3573
3782
  for (let a = 0; a < K; a++) {
3574
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];
3575
3785
  for (let j = 0; j < N; j++) t += Ck[a][j] * x[j];
3576
3786
  out[N + a] = t;
3577
3787
  }
@@ -4755,9 +4965,9 @@ export class Solver {
4755
4965
  }
4756
4966
 
4757
4967
  // The cuts where the impedance may read a small-signal current. Each is the linearised total
4758
- // current toward +x through one segment s of an ideal region, Σ (g + iω d)·δu over the
4759
- // unknowns idx: the Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute) and the
4760
- // displacement's. Without ports it's the same through every cut; with them, only between a
4968
+ // current toward +x through one segment s of a region, Σ (g + iω d)·δu over the unknowns idx:
4969
+ // the Scharfetter–Gummel fluxes' derivatives (as in _segmentsDilute, or _segmentConcentrated
4970
+ // for non-ideal statistics) and the displacement's. Without ports it's the same through every cut; with them, only between a
4761
4971
  // contact and the nearest port's window (see _impedance).
4762
4972
  _currentCuts() {
4763
4973
  const { model, n, M, u, uLo, c, z } = this, F = FARADAY;
@@ -4765,7 +4975,7 @@ export class Solver {
4765
4975
  const cuts = [];
4766
4976
  for (let r = 0; r < regions.length; r++) {
4767
4977
  const reg = regions[r], mat = materials[reg.material];
4768
- if (mat.conductor || !mat.ideal || reg.mixing > 0) continue;
4978
+ if (mat.conductor || reg.mixing > 0) continue;
4769
4979
  const vel = reg.velocity;
4770
4980
  for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
4771
4981
  const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
@@ -4779,6 +4989,15 @@ export class Solver {
4779
4989
  const phiL = u[bL * M], phiR = u[bR * M], hasPhi = !this.phiUndefined[s];
4780
4990
  for (let i = 0; i < n; i++) {
4781
4991
  if (z[i] === 0 || !mat.present[i] || mat.D[i] === 0) continue;
4992
+ if (!mat.ideal) {
4993
+ this._fluxConcentrated(s, bL, bR, mat, h, vel, i);
4994
+ for (let k = 0; k < M; k++) {
4995
+ if (k === 0 && !hasPhi) continue;
4996
+ if (this.jL[k] !== 0) add(bL * M + k, z[i] * F * this.jL[k], 0);
4997
+ if (this.jR[k] !== 0) add(bR * M + k, z[i] * F * this.jR[k], 0);
4998
+ }
4999
+ continue;
5000
+ }
4782
5001
  const zi = z[i], r1 = 1 + i, cL = c[s * n + i], g = mat.D[i] / h;
4783
5002
  const pe = (vel * h) / mat.D[i];
4784
5003
  const d = zi * (phiR - phiL) - pe;