driftlet 1.1.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solver.js CHANGED
@@ -220,6 +220,8 @@ import { EPS0, FARADAY } from './constants.js';
220
220
  import { nextBreakpoint, sourceAt } from './device.js';
221
221
  import { DifferenceTerms } from './difference.js';
222
222
  import { DeviceError } from './errors.js';
223
+ import { chargeOnly, conservedMoieties, feeds, findLaws, noCurrent } from './laws.js';
224
+ import { applyLaws, captureLaws, captureLocal, snapFlat, solveBordered } from './lawRows.js';
223
225
  import { powi, powr } from './pow.js';
224
226
 
225
227
  // A profile's value at x (c0, kf): piecewise linear, constant beyond its ends.
@@ -482,7 +484,7 @@ export class Solver {
482
484
  model.ports.forEach((port, k) => port.capacitance && port.nodes.forEach((g, w) => this.qgTerms[g].push([2 + k, w])));
483
485
  this.contactDOld = { left: 0, right: 0 };
484
486
  this.contactDStart = { left: 0, right: 0 };
485
- this.lawForm = new Map(); // (each circuit law: read as its change this step, see _circuitLaws)
487
+ this.lawForm = new Map(); // (each circuit law: read as its change this step, see src/lawRows.js)
486
488
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
487
489
  // floating (driven by a current, or behind a resistance), and its current into the device.
488
490
  // Sources are read at sourceTime: a step's end, or now for a steady solve.
@@ -493,16 +495,20 @@ export class Solver {
493
495
  this.sourceTime = 0;
494
496
  this.sourceOverride = new Map(); // terminal → voltage, while a steady solve ramps it
495
497
  this.steady = false; // inside a steady solve: sources at the present time, not a step's end
496
- // The floating ones are extra unknowns, solved with the grid's by bordering (_solveBordered).
498
+ // The floating ones are extra unknowns, solved with the grid's by bordering (solveBordered in
499
+ // src/lawRows.js).
497
500
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
498
501
  this.drivenKey = this._drivenKey();
499
- this.lawRefs = new Map(); // what a steady solve keeps behind a terminal at no current (_captureLaws)
502
+ this.lawRefs = new Map(); // what a steady solve keeps behind a terminal at no current (src/lawRows.js)
500
503
  this.chargeRefs = new Map(); // …and the charge a terminal at no current that only charges keeps
501
504
  // Per terminal: B = ∂res/∂V (compact column), C = ∂I/∂x (compact row), ∂I/∂V, and for a
502
505
  // floating one its circuit residual (I − I_set, or I − (V_src − V)/R).
503
506
  this.termB = this.terms.map(() => new Float64Array(this.sys.size + 1));
504
507
  this.termC = this.terms.map(() => new Float64Array(this.sys.size + 1));
505
508
  this.termDI = new Float64Array(this.terms.length);
509
+ // (and a circuit row's derivatives by other terminals' voltages: a law in one's row that counts
510
+ // another electrode's charge)
511
+ this.termDX = this.terms.map(() => new Float64Array(this.terms.length));
506
512
  this.termRes = new Float64Array(this.terms.length);
507
513
  this._refreshSources();
508
514
  // A floating terminal starts level with the first held one (no current).
@@ -733,7 +739,8 @@ export class Solver {
733
739
  // a port). So Ag⁺ between silver electrodes is fed (Ag⁺ + e⁻ ⇌ Ag(s), the electrons fed by the
734
740
  // contacts), while Fe³⁺ and Fe²⁺ between platinum electrodes aren't (Fe³⁺ + e⁻ ⇌ Fe²⁺
735
741
  // conserves the iron, whatever the electrons do).
736
- this.moieties = this._conservedMoieties();
742
+ this.rough = this.stretches.map((st) => this._roughAmount(st));
743
+ this.moieties = conservedMoieties(this._structure());
737
744
  this.stretches.forEach((st, k) => {
738
745
  st.connected = st.contactFed || this.moieties.every((w) => w[k] === 0);
739
746
  });
@@ -750,7 +757,8 @@ export class Solver {
750
757
  // inversion electrons, which reach the back contact only through a bulk with ~1e3 of them per
751
758
  // cm³; blocked SO₄²⁻ driven from a zinc cathode's extended space charge, down to 1e-25 mol/m³
752
759
  // there, whose chain of conductances lost the system 14 digits). So steady solves hold it
753
- // flat (_flatRows): at its contact's level, or reaching none, at the level its amount fixes.
760
+ // flat (flatRows in src/lawRows.js): at its contact's level, or reaching none, at the level
761
+ // its amount fixes.
754
762
  // (Not through flow or mixing, nor a concentrated material's cross-diffusion, where carrying
755
763
  // nothing isn't a level flat.)
756
764
  const plain = (st) => {
@@ -783,7 +791,7 @@ export class Solver {
783
791
  const ks = rx.part.map((p) => this.stretchOf[(f + p.side) * n + p.i]);
784
792
  if (ks.some((k) => k < 0)) continue;
785
793
  join(ks);
786
- rxns.push([ks, (side) => rx.part.reduce((a, p) => a + p.nu * this.contactEta(side, p.i), rx.fixedA)]);
794
+ 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
795
  }
788
796
  });
789
797
  const groups = new Map();
@@ -814,776 +822,39 @@ export class Solver {
814
822
  return t;
815
823
  }
816
824
 
817
- // Whether a stretch's species moves through it (mobile in every region, or a metal's carrier),
818
- // or stays still in every region (D = 0: trap states, conserved node by node).
819
- _moves(st) {
820
- const { regions, materials } = this.model;
821
- for (let q = st.regions[0]; q <= st.regions[1]; q++) {
822
- const mat = materials[regions[q].material];
823
- if (!(mat.conductor || mat.D[st.species] > 0)) return false;
824
- }
825
- return true;
826
- }
827
-
828
- _still(st) {
829
- const { regions, materials } = this.model;
830
- for (let q = st.regions[0]; q <= st.regions[1]; q++) {
831
- const mat = materials[regions[q].material];
832
- if (mat.conductor || mat.D[st.species] > 0) return false;
833
- }
834
- return true;
835
- }
836
-
837
- // Whether terminal k (a contact, or port k − 2) feeds stretch st: links its species there.
838
- _feeds(k, st) {
839
- return k === 0 ? st.leftOpen : k === 1 ? st.rightOpen : st.linked.includes(k - 2);
840
- }
841
-
842
- // What a steady solve keeps (under the terminals' present drives, so again after a change of
843
- // drive): each conserved amount, solved directly in place of one (redundant) balance row, and
844
- // the islands' summed balances.
825
+ // The laws a steady solve keeps (src/laws.js finds them, from the structure alone), set on the
826
+ // solver with the state that goes with them; again after a change of drive.
845
827
  _laws() {
846
- const { model } = this, { regions, materials, grid } = model;
847
- const laws = this._conservedMoieties(true);
848
- for (const st of this.stretches) st.conserved = false;
849
- // Conserved amounts solved directly, each in place of one (redundant) balance row: a
850
- // spectator's own amount (a floating metal's: the charge on its faces), each conserved
851
- // combination of reacting stretches (the total iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose
852
- // weighted balances sum to zero at steady state, and what a terminal driven at no current
853
- // keeps behind it. A combination may take in still stretches (the electrons trap states hold),
854
- // their amounts summed like any other; one of still stretches alone conserves node by node
855
- // instead (below), and one of a stretch still in part only is left to giant time steps.
856
- this.constraints = [];
857
- const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
858
- // The terminals driven by a current that feed a stretch: a combination through one keeps what
859
- // the state holds when a solve begins (see _captureLaws), and has no steady state at any other
860
- // current.
861
- const driven = (st) => this.terms.flatMap((t, k) => (t.drive.kind === 'I' && this._feeds(k, st) ? [k] : []));
862
- const add = (parts, rowStretch, surface = [], circuit = -1, carriers = []) => {
863
- const st = this.stretches[rowStretch];
864
- let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
865
- 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
- for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
867
- for (const p of parts) this.stretches[p.stretch].conserved = true;
868
- this.constraints.push({
869
- parts,
870
- surface,
871
- carriers, // (what a port's electrode holds, Q/(zF) at weight w: see _conservedMoieties)
872
- terminals: [...new Set([...parts.flatMap((p) => driven(this.stretches[p.stretch])), ...carriers.map((p) => p.k)])],
873
- 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
- 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)),
877
- row: circuit >= 0 ? -1 : this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
878
- idx: new Int32Array(nNodes * this.M),
879
- w: new Float64Array(nNodes * this.M),
880
- len: 0,
881
- res: 0,
882
- q: new Float64Array(this.sys.size + 1),
883
- });
884
- };
885
- this.stretches.forEach((st, k) => {
886
- if (!st.spectator || !this._moves(st)) return;
887
- add([{ stretch: k, w: 1 }], k);
888
- // Held flat (see _flatRows), it moves as a whole: the pin's response is the whole stretch's
889
- // shift, its amount saying how far.
890
- 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
- });
892
- 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];
894
- // The rows replaced: a moving stretch's balance (one of still stretches alone is kept node by
895
- // node, below), or the circuit row of a terminal driven at no current, which says nothing the
896
- // combination's balance rows don't (their weighted sum is that terminal's current, weighted
897
- // by w/z). Together they must be independent, each combination's weights over the rows
898
- // replaced a nonsingular matrix, or one balance is lost and another said twice (A + B = AB and
899
- // A + C = AC: −A + B + C in place of B's row, B + AB of AB's and A − B + AC of A's said B and
900
- // A twice and C not at all, and the solve converged to a state that wasn't steady). So they're
901
- // chosen by elimination, each combination's weights reduced by the rows already chosen: a
902
- // circuit row where one is left, else the first stretch with weight 1, else the basis
903
- // vector's own (its weight is 1 there, and no other vector's is), else the largest.
904
- const rowOf = (k) => this.blockOfNode[this.stretches[k].nodes[0]] * this.M + 1 + this.stretches[k].species;
905
- const chosen = []; // (a column and the reduced weights, per row replaced: S + t for terminal t's)
906
- const spectators = new Set(this.constraints.map((cs) => cs.row));
907
- // What an electrode holds goes only in its terminal's circuit row (its charge depends on the
908
- // terminal's voltage, which a balance row's pin can't carry): the combinations through it come
909
- // first, so that the first takes that row, and the rest have its multiple taken off.
910
- const through = (w) => this.carriers.some((k, j) => w[X + j] !== 0);
911
- const held = new Map(); // (terminal → the combination in place of its circuit row)
912
- // Reactions' equilibria first. A face reaction that alone changes some combination (without
913
- // it, w would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
914
- // Ag⁺ + AgNO₃ where it also complexes) has that combination's summed balance rows equal to its
915
- // rate, w·ν times it, which a steady state sets to zero. The rate is a poor row: k₀ times a
916
- // function of the levels, beside diffusion's terms in the same balances. An Ag⁺/Ag couple
917
- // 1e-20 as fast as Fe³⁺/Fe²⁺ on the same platinum was lost to their round-off, the
918
- // open-circuit potential settling where silver wasn't (−0.4661 V, the joint equilibrium
919
- // −0.5004 V), and 1e-15 to 1e-18 failed. Its affinity says the same at any k₀ (each kinetic
920
- // form vanishes only there), so it stands in for one of those rows (see _applyAffinities),
921
- // chosen by the same elimination, and a stretch's: never a circuit row.
922
- // The slowest first, whose rate rows are the poorest, and each row picked by the reaction's
923
- // own terms, never by where its species are listed: the same rows in any listing. (Picked by
924
- // listing, an electrode's two couples took different rows with the species reversed, and
925
- // Newton converged in one listing and crawled in the other.)
926
- this.affinities = [];
927
- const faceRx = model.interfaces.flatMap((itf, f) => itf.reactions.map((rx) => ({ f, rx, key: rx.part.map((p) => `${model.species[p.i].name}:${p.side}:${p.nu}`).sort().join() })));
928
- faceRx.sort((a, b) => a.rx.k0 - b.rx.k0 || (a.key < b.key ? -1 : a.key > b.key ? 1 : a.f - b.f));
929
- for (const { f, rx } of faceRx) {
930
- const nu = new Float64Array(S);
931
- let inFlat = false;
932
- for (const p of rx.part) {
933
- const k = this.stretchOf[(f + p.side) * this.n + p.i];
934
- if (k < 0 || this.flatStretches.includes(this.stretches[k])) inFlat = true;
935
- else nu[k] += p.nu;
936
- }
937
- if (inFlat) continue; // (held flat at a contact's levels, in equilibrium already)
938
- // (Not one fed by a terminal driven by a current: its sum is the rate plus what that
939
- // passes. Silver's, e⁻ + Fe²⁺ with the electrons fed, or Ag⁺ + AgNO₃, which nothing is.
940
- // A current held at zero passes nothing: an electrode at open circuit feeding a closed
941
- // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
942
- 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))));
944
- // Any of the combinations it alone changes will do, with any conserved amount added (whose
945
- // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
946
- // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
947
- const news = this._conservedMoieties(true, [], rx).filter((w) => Math.abs(nu.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9 && !fed(w));
948
- const kept = laws.filter((l) => !fed(l));
949
- const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
950
- for (const w of tries) {
951
- const v = new Float64Array(S + T);
952
- for (let k = 0; k < S; k++) {
953
- if (w[k] === 0) continue;
954
- const st = this.stretches[k];
955
- if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = w[k];
956
- if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = w[k] / this.z[st.species];
957
- }
958
- for (const [col, row] of chosen) {
959
- const fc = v[col];
960
- if (fc !== 0) for (let j = 0; j < S + T; j++) v[j] -= fc * row[j];
961
- }
962
- const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
963
- if (!(scale > 1e-9)) continue;
964
- // (Never a metal's carriers, nor a stretch a terminal feeds, even at no current: their rows
965
- // carry the terminal's current, and Pt's electrons standing for an iron couple's balance
966
- // put the electrode at 602 V.)
967
- const ok = (j) => j !== undefined && j >= 0 && j < S && Math.abs(v[j]) > 1e-9 * scale && !conductor(this.stretches[j]) && this.terms.every((_, t) => !this._feeds(t, this.stretches[j]));
968
- // (One of its own species first, then weight 1, the largest, and by name.)
969
- const own = (k) => rx.part.some((p) => p.i === this.stretches[k].species && this.stretchOf[(f + p.side) * this.n + p.i] === k);
970
- const key = (k) => [own(k) ? 0 : 1, Math.abs(v[k]) === 1 ? 0 : 1, -Math.abs(v[k]), model.species[this.stretches[k].species].name, this.stretches[k].regions[0]];
971
- const less = (a, b) => { const ka = key(a), kb = key(b); for (let j = 0; j < ka.length; j++) if (ka[j] !== kb[j]) return ka[j] < kb[j]; return false; };
972
- const col = [...Array(S).keys()].filter(ok).reduce((b, k) => (b < 0 || less(k, b) ? k : b), -1);
973
- if (col < 0) continue;
974
- chosen.push([col, v.map((x) => x / v[col])]);
975
- this.affinities.push({ rx, f, row: rowOf(col), idx: new Int32Array(rx.part.length), w: new Float64Array(rx.part.length), len: 0, res: 0 });
976
- break;
977
- }
978
- }
979
- for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
980
- for (const [j, k] of this.carriers.entries()) {
981
- const c = held.get(k);
982
- if (w[X + j] === 0 || !c) continue;
983
- const f = w[X + j] / c[X + j], own = w.own;
984
- w = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
985
- w.own = own;
986
- }
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] }));
989
- 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)
991
- if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
992
- if (parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
993
- const v = new Float64Array(S + T);
994
- for (const { stretch: k, w: wk } of parts) {
995
- const st = this.stretches[k];
996
- if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = wk;
997
- if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = wk / this.z[st.species];
998
- }
999
- for (const { k, w: wk } of carriers) v[S + k] = wk / zc(k);
1000
- for (const [col, row] of chosen) {
1001
- const f = v[col];
1002
- if (f !== 0) for (let j = 0; j < S + T; j++) v[j] -= f * row[j];
1003
- }
1004
- const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
1005
- if (!(scale > 1e-9)) continue;
1006
- const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
1007
- 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)
1009
- 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);
1012
- }
1013
- // A combination that nothing carries anywhere is conserved node by node: of still stretches
1014
- // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
1015
- // that only turn into each other). At each node its weighted sum stays what it was, a row local
1016
- // to that node's block, in place of one of their balances.
1017
- this.localConstraints = [];
1018
- const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
1019
- for (const w of laws) {
1020
- 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]);
1026
- 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],
1030
- reference: new Float64Array(sets[0].length),
1031
- });
1032
- }
1033
- // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
1034
- // contact, port or reaction) holds its level only through its faces' fluxes, which can be
1035
- // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
1036
- // conducts): eliminated, the level is lost to round-off, and Newton settles at a wrong one with
1037
- // nothing to show for it. In steady solves the piece's balance summed over its nodes, where
1038
- // the internal fluxes cancel exactly and the faces' alone are left, replaces its first node's
1039
- // (see _applyIslands), once the plain solves converge (see newton).
1040
- this.islands = [];
828
+ const found = findLaws(this._structure());
829
+ for (const [k, st] of this.stretches.entries()) st.conserved = found.conserved.has(k);
830
+ this.constraints = found.constraints;
831
+ this.affinities = found.affinities;
832
+ this.localConstraints = found.localConstraints;
833
+ this.islands = found.islands;
834
+ this.timeLaws = found.timeLaws;
835
+ this.contactLaw = found.contactLaw;
836
+ this.carriers = found.carriers;
837
+ this.unkept = found.unkept; // (laws no row here can keep: see _directSteady)
838
+ this.contactQ = [null, null]; // (each contact's charge as _contact captures it, for contactLaw)
1041
839
  this.pinnedIslands = [];
1042
840
  this.islandsOn = false; // (newton() turns them on, see there)
1043
841
  this.pins = [];
1044
- const used = new Set([...this.constraints.map((cs) => cs.row).filter((o) => o >= 0), ...this.affinities.map((af) => af.row)]);
1045
- for (const st of this.stretches) {
1046
- const i = st.species, [s0, s1] = st.regions;
1047
- if (!st.mobile || conductor(st)) continue;
1048
- for (let q0 = s0, q1 = s0; q1 <= s1; q1++) {
1049
- const p0 = q0;
1050
- q0 = q1 + 1;
1051
- if (p0 === s0 && q1 === s1) continue; // (uncut)
1052
- if ((p0 === s0 && st.leftOpen) || (q1 === s1 && st.rightOpen)) continue;
1053
- const inside = (q) => q >= p0 && q <= q1;
1054
- if (model.ports.some((port) => inside(port.region) && (port.species[i].type !== 'blocked' || port.reactions.some((rx) => rx.part.some((p) => p.side === 0 && p.i === i))))) continue;
1055
- let reactive = false;
1056
- for (let q = p0; q <= q1; q++) {
1057
- for (const rx of model.reactions) if (rx.kf[regions[q].material] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
1058
- }
1059
- for (let f = Math.max(0, p0 - 1); f <= Math.min(model.interfaces.length - 1, q1); f++) {
1060
- for (const rx of model.interfaces[f].reactions) if (rx.part.some((p) => p.i === i && inside(f + p.side))) reactive = true;
1061
- }
1062
- const row = this.blockOfNode[grid.regionStart[p0]] * this.M + 1 + i;
1063
- if (reactive || used.has(row)) continue;
1064
- used.add(row);
1065
- // The faces' fluxes into the piece: −A_f u at its left face, +A_f u at its right. Across
1066
- // a face that holds μ̄ level, u is set only by the edge balances, and eliminated, it can
1067
- // come out of the piece's own (G Δη, its two η all but equal: a neighbour 1e20 times
1068
- // less conductive, and the piece's level was seen through one face of two, and Newton
1069
- // swung it back and forth across both). There the outside edge node's balance is added:
1070
- // u cancels, and the outside's first segment carries the flux instead.
1071
- const faces = [], outside = [];
1072
- if (p0 > s0) faces.push([p0 - 1, -1, grid.regionEnd[p0 - 1]]);
1073
- if (q1 < s1) faces.push([q1, 1, grid.regionStart[q1 + 1]]);
1074
- for (const [f, , g] of faces) {
1075
- if (model.interfaces[f].links[i].type !== 'equilibrium' || g === 0 || g === this.nNodes - 1) continue;
1076
- if (model.ports.some((port) => port.nodes.includes(g))) continue;
1077
- outside.push(this.blockOfNode[g] * this.M + 1 + i);
1078
- }
1079
- this.islands.push({
1080
- stretch: st,
1081
- row,
1082
- flux: faces.map(([f]) => this.blockOfFace[f] * this.M + 1 + i),
1083
- weight: faces.map(([f, sg]) => sg * grid.area[grid.regionEnd[f]]),
1084
- outside,
1085
- idx: new Int32Array(faces.length + 3 * this.M * outside.length),
1086
- w: new Float64Array(faces.length + 3 * this.M * outside.length),
1087
- len: 0,
1088
- res: 0,
1089
- });
1090
- }
1091
- }
1092
- for (const isl of this.islands) isl.outside = isl.outside.filter((o) => !used.has(o));
1093
-
1094
- // Time steps only: a terminal behind a resistance that alone feeds a combination reads its
1095
- // current by that combination's change too (see _circuitLaws), its combinations found with it
1096
- // counted as driven by a current. (Not in the steady system, where it holds its level.)
1097
- this.timeLaws = [];
1098
- const behind = this.floating.filter((k) => this.terms[k].drive.kind !== 'I');
1099
- if (behind.length > 0) {
1100
- const steadyCarriers = this.carriers, more = this._conservedMoieties(true, behind), X2 = S + this.surfCols.length;
1101
- for (const w of more) {
1102
- const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
1103
- if (parts.length === 0 || parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
1104
- const surface = [...w.keys()].filter((k) => k >= S && k < X2 && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
1105
- const carriers = this.carriers.flatMap((k, j) => (w[X2 + j] !== 0 ? [{ k, w: w[X2 + j] }] : []));
1106
- // Fed by one terminal behind a resistance and nothing else that passes current.
1107
- const fedBy = new Set([...carriers.map((p) => p.k), ...parts.flatMap((p) => this.floating.filter((k) => this._feeds(k, this.stretches[p.stretch])))]);
1108
- if (fedBy.size !== 1) continue;
1109
- const [k] = fedBy;
1110
- 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;
1112
- 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
- if (!lambda) continue;
1114
- let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
1115
- for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
1116
- for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
1117
- this.timeLaws.push({ parts, surface, carriers, terminals: [k], circuit: k, lambda, idx: new Int32Array(nNodes * this.M), w: new Float64Array(nNodes * this.M), len: 0 });
1118
- }
1119
- this.carriers = steadyCarriers;
1120
- }
1121
- }
1122
-
1123
- // What each combination fed by a terminal driven at no current holds now, which a steady solve
1124
- // keeps: a closed battery at open circuit stays at the charge it had.
1125
- _captureLaws() {
1126
- // A terminal at no current that passes current only by charging a capacitance keeps its charge.
1127
- this.chargeRefs.clear();
1128
- const charged = this.floating.filter((k) => this.terms[k].drive.kind === 'I' && this._chargeOnly(k));
1129
- if (charged.length > 0) {
1130
- this._assembleBookkeeping(Infinity);
1131
- for (const k of charged) this.chargeRefs.set(k, k >= 2 ? this.portQ[k - 2] : k === 0 ? this.contactD.left : -this.contactD.right);
1132
- }
1133
- 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);
1135
- for (const cs of this.constraints) {
1136
- if (cs.terminals.length === 0) continue;
1137
- let a = 0;
1138
- for (const { stretch, w } of cs.parts) a += w * this.amount(this.stretches[stretch]);
1139
- 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);
1141
- this.lawRefs.set(cs.key, a);
1142
- }
1143
- }
1144
-
1145
- // Each local constraint's weighted sum at each node, as the state holds it now: what a steady
1146
- // solve keeps.
1147
- _captureLocal() {
1148
- const { n, c, th, nSurf } = this;
1149
- for (const lc of this.localConstraints) {
1150
- lc.nodes.forEach((g, j) => {
1151
- let t = 0;
1152
- for (const { i, q, w } of lc.parts) t += w * (i !== undefined ? c[g * n + i] : th[g * nSurf + q]);
1153
- lc.reference[j] = t;
1154
- });
1155
- }
1156
- }
1157
-
1158
- // The islands' summed balances (steady solves only): Σ A_f u over the faces into each, its
1159
- // first node's row pinned in the factorised matrix and the sum bordered (_solveBordered) like a
1160
- // conserved amount's. The pieces of a stretch held flat by its contact are left as they are.
1161
- _applyIslands() {
1162
- const { u, res } = this, R = this.rix, out = [];
1163
- for (const isl of this.islands) {
1164
- if (this.flatStretches.includes(isl.stretch)) continue;
1165
- const b0 = Math.floor(isl.row / this.M), r0 = isl.row % this.M;
1166
- if (this.loc[isl.row] < 0) continue;
1167
- // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
1168
- // exactly where they meet), then the nonzeros kept.
1169
- const acc = this.islandRow ?? (this.islandRow = new Float64Array(this.sys.size + 1));
1170
- const cols = [];
1171
- const add = (col, v) => {
1172
- if (acc[col] === 0) cols.push(col);
1173
- acc[col] += v;
1174
- };
1175
- let t = 0;
1176
- isl.flux.forEach((o, j) => {
1177
- if (this.loc[o] < 0) return;
1178
- t += isl.weight[j] * u[o];
1179
- add(R[o], isl.weight[j]);
1180
- });
1181
- for (const o of isl.outside) {
1182
- if (this.loc[o] < 0) continue;
1183
- t += res[R[o]];
1184
- this._rowInto(o, add);
1185
- }
1186
- let len = 0;
1187
- for (const col of cols) {
1188
- if (col < this.sys.size && acc[col] !== 0) {
1189
- isl.idx[len] = col;
1190
- isl.w[len++] = acc[col];
1191
- }
1192
- acc[col] = 0;
1193
- }
1194
- isl.len = len;
1195
- isl.res = t;
1196
- this._replaceRow(b0, r0);
1197
- for (const B of this.termB) B[R[isl.row]] = 0;
1198
- this._j(b0, r0, b0, r0, 1);
1199
- res[R[isl.row]] = 0;
1200
- out.push(isl);
1201
- }
1202
- return out;
1203
842
  }
1204
843
 
1205
- // Row o's Jacobian entries, as add(compact column, value): the assembled ones, and the dilute
1206
- // kernels' kept aside in difference form (see _assembleDifference) when they are.
1207
- _rowInto(o, add) {
1208
- const { sys, M, nB } = this, b = Math.floor(o / M), l = this.loc[o];
1209
- const { A, B, C, sizes, offA, offB, offC, offX } = sys, m = sizes[b];
1210
- for (let k = 0; k < m; k++) if (B[offB[b] + l * m + k] !== 0) add(offX[b] + k, B[offB[b] + l * m + k]);
1211
- if (b > 0) for (let k = 0, mp = sizes[b - 1]; k < mp; k++) if (A[offA[b] + l * mp + k] !== 0) add(offX[b - 1] + k, A[offA[b] + l * mp + k]);
1212
- if (b < nB - 1) for (let k = 0, mn = sizes[b + 1]; k < mn; k++) if (C[offC[b] + l * mn + k] !== 0) add(offX[b + 1] + k, C[offC[b] + l * mn + k]);
1213
- if (this.lin) {
1214
- const into = this.linRow ?? (this.linRow = new Float64Array(sys.size + 1));
1215
- this.lin.capture(this.rix[o], 1, 0, into);
1216
- for (let k = 0; k < sys.size; k++) {
1217
- if (into[k] !== 0) add(k, into[k]);
1218
- into[k] = 0;
1219
- }
1220
- }
1221
- }
1222
-
1223
- // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
1224
- _applyLocalConstraints() {
1225
- const { n, M, c, th, nSurf, res, dA: d } = this;
1226
- for (const lc of this.localConstraints) {
1227
- lc.nodes.forEach((g, j) => {
1228
- const b = this.blockOfNode[g];
1229
- if (this.loc[b * M + lc.row] < 0) return;
1230
- this._replaceRow(b, lc.row);
1231
- let t = 0;
1232
- for (const { i, q, w } of lc.parts) {
1233
- if (i !== undefined) {
1234
- t += w * c[g * n + i];
1235
- this._dc(g, i, d);
1236
- for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1237
- continue;
1238
- }
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]));
1243
- }
1244
- res[this.rix[b * M + lc.row]] = t - lc.reference[j];
1245
- });
1246
- }
844
+ // What src/laws.js reads (see its contract): the device's structure, never the state.
845
+ _structure() {
846
+ const { model, n, M, z, nNodes, stretches, stretchOf, flatStretches, rough, surfCols, surfColOf, terms, floating } = this;
847
+ const { blockOfNode, blockOfFace, rxsIn, nodeMaterial, phiUndefined } = this;
848
+ return { model, n, M, z, nNodes, stretches, stretchOf, flatStretches, rough, surfCols, surfColOf, terms, floating, blockOfNode, blockOfFace, rxsIn, nodeMaterial, phiUndefined };
1247
849
  }
1248
850
 
1249
- // A basis of the conserved combinations of stretch amounts: the null space of the
1250
- // stoichiometry (a row per reaction, over the stretches it touches) together with what the
1251
- // terminals feed. A terminal held at a voltage (or behind a resistance, which holds it at
1252
- // steady state) feeds each stretch it links freely: a unit row. With `drives`, one driven by a
1253
- // current feeds its charged species only as that current allows, in proportion to their
1254
- // charges, so a combination weighted by z there is conserved while it passes none: a battery's
1255
- // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
1256
- // elimination will do.
1257
- _conservedMoieties(drives = false, alsoCurrent = [], skip = null) {
1258
- const { model, n, z } = this, S = this.stretches.length;
1259
- // With `drives`, an electrode port driven by a current whose reactions take its carrier from
1260
- // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
1261
- // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
1262
- // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
1263
- 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] : [])) : [];
1265
- const X = S + this.surfCols.length, C = X + this.carriers.length;
1266
- // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1267
- // its own rows, over the columns, are handed back as `basis.skipped`.)
1268
- const rows = [], skipped = [];
1269
- model.interfaces.forEach((itf, f) => {
1270
- for (const rx of itf.reactions) {
1271
- const row = new Float64Array(C);
1272
- for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
1273
- (rx === skip ? skipped : rows).push(row);
1274
- }
1275
- });
1276
- // An electrode surface's species are columns too, after the stretches (S + j for surface
1277
- // column j), so a combination that passes through them (A⁺ + e⁻ = S, S + e⁻ = B⁻) is conserved
1278
- // with what the surface holds.
1279
- model.ports.forEach((port, k) => {
1280
- for (const rx of port.reactions) {
1281
- const row = new Float64Array(C); // (the electrode's carrier is outside the device)
1282
- if (rx === skip) skipped.push(row);
1283
- for (const p of rx.part) {
1284
- if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
1285
- else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
1286
- else if (this.carriers.includes(k + 2)) row[X + this.carriers.indexOf(k + 2)] += p.nu;
1287
- }
1288
- if (rx !== skip) rows.push(row);
1289
- }
1290
- });
1291
- model.regions.forEach((reg, q) => {
1292
- for (const rx of model.reactions) {
1293
- if (!(rx.kf[reg.material] > 0)) continue;
1294
- const row = new Float64Array(C);
1295
- for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
1296
- for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
1297
- (rx === skip ? skipped : rows).push(row);
1298
- }
1299
- });
1300
- const unit = (k) => {
1301
- const row = new Float64Array(C);
1302
- row[k] = 1;
1303
- rows.push(row);
1304
- };
1305
- this.terms.forEach((t, k) => {
1306
- const fed = this.stretches.flatMap((st, j) => (this._feeds(k, st) ? [j] : []));
1307
- if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
1308
- 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]]);
1310
- fed.filter((j) => z[this.stretches[j].species] === 0).forEach(unit);
1311
- for (let a = 1; a < charged.length; a++) {
1312
- const row = new Float64Array(C);
1313
- row[charged[0][0]] = 1 / charged[0][1];
1314
- row[charged[a][0]] = -1 / charged[a][1];
1315
- rows.push(row);
1316
- }
1317
- });
1318
- // 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).
1321
- // Then the stretches by their starting amounts, least first, and by name: a trace species
1322
- // pivoted after an abundant one comes out added to it (A + B ⇌ C with B at 1 M and A at 1e-14
1323
- // 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);
1325
- const rough = this.stretches.map((st) => this._roughAmount(st)), name = (k) => model.species[this.stretches[k].species].name;
1326
- const order = [...Array(C).keys()].sort((a, b) => {
1327
- if (still(a) !== still(b)) return still(b) - still(a);
1328
- if (a >= S || b >= S) return a - b;
1329
- if (rough[a] !== rough[b]) return rough[a] - rough[b];
1330
- const na = name(a), nb = name(b);
1331
- return na < nb ? -1 : na > nb ? 1 : a - b;
1332
- });
1333
- const pivots = [];
1334
- let r = 0;
1335
- for (const col of order) {
1336
- if (r >= rows.length) break;
1337
- let best = r;
1338
- for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
1339
- if (Math.abs(rows[best][col]) < 1e-9) continue;
1340
- [rows[r], rows[best]] = [rows[best], rows[r]];
1341
- const pr = rows[r], pv = pr[col];
1342
- for (let j = 0; j < C; j++) pr[j] /= pv;
1343
- for (let i = 0; i < rows.length; i++) {
1344
- if (i === r || rows[i][col] === 0) continue;
1345
- const fct = rows[i][col];
1346
- for (let j = 0; j < C; j++) rows[i][j] -= fct * pr[j];
1347
- }
1348
- pivots.push(col);
1349
- r++;
1350
- }
1351
- const isPivot = new Uint8Array(C);
1352
- for (const col of pivots) isPivot[col] = 1;
1353
- const basis = [];
1354
- for (let free = 0; free < C; free++) {
1355
- if (isPivot[free]) continue;
1356
- const w = new Float64Array(C);
1357
- w[free] = 1;
1358
- pivots.forEach((col, i) => {
1359
- const v = -rows[i][free];
1360
- w[col] = Math.abs(v) < 1e-9 ? 0 : v;
1361
- });
1362
- w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
1363
- basis.push(w);
1364
- }
1365
- basis.skipped = skipped;
1366
- return basis;
1367
- }
1368
-
1369
- // Every stretch either reaches a contact or is a mobile spectator: the steady equations can
1370
- // be solved directly (dt = ∞), with the spectators' amounts as constraints.
851
+ // Every stretch either reaches a contact or is kept by a law, and no law is left unkept: the
852
+ // steady equations can be solved directly (dt = ∞), with the laws in place of their rows. Else
853
+ // the steady solve goes by time steps, which keep every amount themselves.
1371
854
  _directSteady() {
1372
- return this.stretches.every((st) => st.connected || st.conserved);
1373
- }
1374
-
1375
- // Conservation rows for the spectators (steady solves only): Σ v c_i = amount over the
1376
- // stretch replaces the balance row of its first node, which in steady state is the negative
1377
- // sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
1378
- // (identity row) there instead, and _solveBordered restores the constraint.
1379
- _applyConstraints() {
1380
- const { M, res } = this;
1381
- for (const cs of this.constraints) {
1382
- const { amount, reference, dV } = this._lawAmount(cs);
1383
- cs.res = amount - (cs.terminals.length > 0 ? (this.lawRefs.get(cs.key) ?? amount) : reference);
1384
- if (cs.circuit >= 0) {
1385
- // In place of the terminal's circuit row (its I = 0).
1386
- const C = this.termC[cs.circuit];
1387
- C.fill(0);
1388
- for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += cs.w[j];
1389
- this.termRes[cs.circuit] = cs.res;
1390
- this.termDI[cs.circuit] = dV;
1391
- continue;
1392
- }
1393
- const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
1394
- this._replaceRow(b0, r0);
1395
- this._j(b0, r0, b0, r0, 1);
1396
- res[this.rix[cs.row]] = 0;
1397
- }
1398
- }
1399
-
1400
- // A conserved combination's amount as the state holds it (and its reference, closed), with its
1401
- // derivatives in cs.idx/w (and dV, by the voltage of a terminal whose electrode's charge it
1402
- // counts).
1403
- _lawAmount(cs) {
1404
- const { n, M, c, dA: d } = this;
1405
- const vol = this.model.grid.vol;
1406
- let amount = 0, reference = 0, len = 0, gross = 0;
1407
- for (const { stretch, w } of cs.parts) {
1408
- const st = this.stretches[stretch], i = st.species;
1409
- if (cs.terminals.length === 0) reference += w * this.referenceAmounts[stretch];
1410
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
1411
- amount += w * vol[g] * c[g * n + i];
1412
- gross += Math.abs(w * vol[g] * c[g * n + i]);
1413
- if (this.nodeConductor[g] >= 0) {
1414
- // A metal's carriers: the sheet at a charged face, vol·c = ±D_f·A/(zF), held by the
1415
- // face's displacement.
1416
- const f = this.sheetFace[g];
1417
- if (f >= 0 && this.loc[this.blockOfFace[f] * M] >= 0) {
1418
- cs.idx[len] = this.rix[this.blockOfFace[f] * M];
1419
- cs.w[len++] = (w * this.sheetSign[g] * this.model.grid.area[g]) / (this.z[i] * FARADAY);
1420
- }
1421
- continue;
1422
- }
1423
- this._dc(g, i, d);
1424
- const b = this.blockOfNode[g];
1425
- for (let r = 0; r < M; r++) {
1426
- if (d[r] === 0 || this.loc[b * M + r] < 0) continue;
1427
- cs.idx[len] = this.rix[b * M + r];
1428
- cs.w[len++] = w * vol[g] * d[r];
1429
- }
1430
- }
1431
- }
1432
- // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
1433
- for (const { col, w } of cs.surface) {
1434
- const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
1435
- if (cs.terminals.length === 0) reference += w * this.surfaceRef[col];
1436
- port.nodes.forEach((g, j) => {
1437
- const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
1438
- amount += f * tq;
1439
- gross += Math.abs(f * tq);
1440
- for (let x = 0; x < ns; x++) {
1441
- cs.idx[len] = this.rix[b * M + 1 + n + x];
1442
- cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
1443
- }
1444
- });
1445
- }
1446
- // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
1447
- // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as _portCapacitance has it).
1448
- let dV = 0;
1449
- for (const { k, w } of cs.carriers) {
1450
- const port = this.model.ports[k - 2];
1451
- if (!port.capacitance) continue;
1452
- const f = w / (this.z[port.terminal] * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k];
1453
- const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
1454
- port.nodes.forEach((g, j) => {
1455
- if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
1456
- const b = this.blockOfNode[g], s = vol[g] * this.portArea[k - 2][j] * Cs;
1457
- amount += f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M]));
1458
- gross += Math.abs(f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M])));
1459
- dV += f * s;
1460
- cs.idx[len] = this.rix[b * M];
1461
- cs.w[len++] = -f * s * this.VT;
1462
- });
1463
- }
1464
- cs.len = len;
1465
- return { amount, reference, dV, gross };
855
+ return this.unkept.length === 0 && this.stretches.every((st) => st.connected || st.conserved);
1466
856
  }
1467
857
 
1468
- // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
1469
- _lawStart(cs) {
1470
- const { n, nSurf } = this, vol = this.model.grid.vol;
1471
- let a = 0;
1472
- for (const { stretch, w } of cs.parts) {
1473
- const st = this.stretches[stretch], i = st.species;
1474
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * this.cOld[g * n + i];
1475
- }
1476
- for (const { col, w } of cs.surface) {
1477
- const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity;
1478
- port.nodes.forEach((g, j) => (a += w * G * vol[g] * this.portArea[k][j] * this.thOld[g * nSurf + q]));
1479
- }
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);
1481
- return a;
1482
- }
1483
-
1484
- // Time steps: a terminal driven by a current that alone feeds a conserved combination can read
1485
- // its current as the combination's change, F (S − S*)/(λ dt), which the step's balance rows sum
1486
- // to exactly, rather than as the flux through it. Both are exact; they differ in round-off and
1487
- // in what Newton can see. Through a metal, the flux is G Δη, its two η all but equal: a floating
1488
- // gate's charging current fell below that at steps of ~0.03 s, and the step came out singular.
1489
- // Over long steps the change is the better kept (a closed host at open circuit, read by its
1490
- // flux, drifted from its charge by 3 mV over 5e11 s, and its steps failed by 4e12 s). Over
1491
- // short ones it's the amount's round-off over dt (a battery's cathode holds 0.04 mol/m² of
1492
- // electrons: 1e-6 A/m² at steps of 1e-6 s, enough to reject steps at tol 1e-8), where the flux,
1493
- // its levels carried in two words (u + uLo), is far cleaner than ε G. So a step reads the change
1494
- // where that's ε·F·S/(λ dt) below 1e-6 of the flux's ε G, as its start has them, keeps to it
1495
- // throughout, and where a step read by the flux fails, it's tried once more read by the change
1496
- // (see step).
1497
- _circuitLaws(dt) {
1498
- for (const cs of [...this.constraints, ...this.timeLaws]) {
1499
- const k = cs.circuit;
1500
- if (k < 0 || !this.floating.includes(k)) continue;
1501
- const { amount, dV, gross } = this._lawAmount(cs), f = FARADAY / (cs.lambda * dt), C = this.termC[k];
1502
- if (!this.lawForm.has(cs)) {
1503
- let G = 0;
1504
- for (let j = 0; j < C.length; j++) G = Math.max(G, Math.abs(C[j]));
1505
- this.lawForm.set(cs, Math.abs(f) * gross < 1e-6 * G);
1506
- }
1507
- if (!this.lawForm.get(cs)) continue;
1508
- C.fill(0);
1509
- for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
1510
- const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1511
- 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);
1513
- }
1514
- }
1515
-
1516
- // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
1517
- // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
1518
- // the rows pinned in T: the spectators' conserved amounts (see _applyConstraints) and the
1519
- // islands' summed balances (see _applyIslands). By low-rank
1520
- // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
1521
- // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
1522
- // system for the μ (pins) and δV (terminals):
1523
- // pin q: Σ (W_q·Q_q′) μ_q′ − Σ (W_q·X_k) δV_k = res_q − W_q·y
1524
- // terminal k: Σ (C_k·Q_q) μ_q + Σ (δ_kk′ ∂I_k/∂V_k − C_k·X_k′) δV_k′ = res_k − C_k·y
1525
- _solveBordered(rhs, delta, deltaV, pins) {
1526
- const N = this.sys.size, fl = this.floating, P = pins.length, K = fl.length, S = P + K;
1527
- this._solveLinear(rhs, delta);
1528
- if (S === 0) return;
1529
- const e = this.dWork ?? (this.dWork = new Float64Array(N + 1));
1530
- const cols = this.borderCols ?? (this.borderCols = []);
1531
- for (let a = 0; a < S; a++) {
1532
- if (!cols[a]) cols[a] = new Float64Array(N + 1);
1533
- if (a < P) {
1534
- // The response to a unit pin, or where the pin moves a flat stretch, to its shift.
1535
- const col = pins[a].col ?? [pins[a].row];
1536
- for (const o of col) e[this.rix[o]] = 1;
1537
- this._solveLinear(e, cols[a]);
1538
- for (const o of col) e[this.rix[o]] = 0;
1539
- } else this._solveLinear(this.termB[fl[a - P]], cols[a]);
1540
- }
1541
- // Row a of the small system, as a dot product with a compact vector (sparse for pins).
1542
- const dot = (a, v) => {
1543
- let t = 0;
1544
- if (a < P) {
1545
- const p = pins[a];
1546
- for (let j = 0; j < p.len; j++) t += p.w[j] * v[p.idx[j]];
1547
- } else {
1548
- const C = this.termC[fl[a - P]];
1549
- for (let j = 0; j < N; j++) t += C[j] * v[j];
1550
- }
1551
- return t;
1552
- };
1553
- const A = Array.from({ length: S }, () => new Float64Array(S + 1));
1554
- for (let a = 0; a < S; a++) {
1555
- 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]];
1557
- A[a][S] = (a < P ? pins[a].res : this.termRes[fl[a - P]]) - dot(a, delta);
1558
- let mx = 0;
1559
- for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
1560
- if (mx > 0) for (let b = 0; b <= S; b++) A[a][b] /= mx;
1561
- }
1562
- // Small dense solve with partial pivoting.
1563
- for (let col = 0; col < S; col++) {
1564
- let p = col;
1565
- for (let r = col + 1; r < S; r++) if (Math.abs(A[r][col]) > Math.abs(A[p][col])) p = r;
1566
- [A[col], A[p]] = [A[p], A[col]];
1567
- if (A[col][col] === 0) throw new SolverError('the bordered system is singular: a conserved amount or a floating terminal is not determined');
1568
- for (let r = col + 1; r < S; r++) {
1569
- const f = A[r][col] / A[col][col];
1570
- for (let k = col; k <= S; k++) A[r][k] -= f * A[col][k];
1571
- }
1572
- }
1573
- const x = new Float64Array(S);
1574
- for (let r = S - 1; r >= 0; r--) {
1575
- let v = A[r][S];
1576
- for (let k = r + 1; k < S; k++) v -= A[r][k] * x[k];
1577
- x[r] = v / A[r][r];
1578
- }
1579
- for (let a = 0; a < S; a++) {
1580
- const sa = a < P ? x[a] : -x[a], col = cols[a];
1581
- for (let j = 0; j < N; j++) delta[j] += sa * col[j];
1582
- if (a >= P) deltaV[a - P] = x[a];
1583
- }
1584
- }
1585
-
1586
-
1587
858
  // An electrode surface at its starting coverages at node g: η = μ°/RT + ln(θ/θ₀).
1588
859
  _surfaceStart(g) {
1589
860
  const { n, M, u, model } = this, surf = model.ports[this.surfPort[g]].surface, b = this.blockOfNode[g];
@@ -1616,7 +887,7 @@ export class Solver {
1616
887
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
1617
888
  if (this.floating.length !== before.length) this.deltaV = null;
1618
889
  // A terminal newly driven by a current (or no longer) can close a combination behind it.
1619
- // (and one put behind a resistance, or taken from it, its time steps' reading: see _laws)
890
+ // (and one put behind a resistance, or taken from it, its time steps' reading: see src/laws.js)
1620
891
  this.drivenKey = this._drivenKey();
1621
892
  if (this.drivenKey !== driven) this._laws();
1622
893
  this._refreshSources();
@@ -1624,17 +895,17 @@ export class Solver {
1624
895
  }
1625
896
 
1626
897
  // After a steady solve: each reaction that alone changes some combination nothing else feeds
1627
- // (see _laws), whose total rate a steady state therefore sets to zero, but whose affinity has one
898
+ // (see src/laws.js), whose total rate a steady state therefore sets to zero, but whose affinity has one
1628
899
  // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
1629
900
  // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
1630
901
  // port's slow couple at its starting composition, each converged without a word. (A face
1631
902
  // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
1632
903
  stuckReactions() {
1633
904
  const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
1634
- const passes = (k) => !this._noCurrent(this.terms[k]);
1635
- const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && this.terms.some((t, j) => t.drive.kind === 'I' && passes(j) && this._feeds(j, this.stretches[k])))));
905
+ const passes = (k) => !noCurrent(this.terms[k]);
906
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && this.terms.some((t, j) => t.drive.kind === 'I' && passes(j) && feeds(j, this.stretches[k])))));
1636
907
  const needed = (rx) => {
1637
- const basis = this._conservedMoieties(true, [], rx);
908
+ const basis = conservedMoieties(this._structure(), { drives: true, skip: rx });
1638
909
  return basis.some((w) => !fed(w) && basis.skipped.some((row) => Math.abs(row.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9));
1639
910
  };
1640
911
  const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
@@ -1667,15 +938,11 @@ export class Solver {
1667
938
  return out;
1668
939
  }
1669
940
 
1670
- // How each terminal is driven, as far as the laws care (see _laws): by a voltage or a current,
941
+ // How each terminal is driven, as far as the laws care (see src/laws.js): by a voltage or a current,
1671
942
  // behind a resistance or not, and a current that's held at zero throughout (which feeds a
1672
943
  // combination nothing, so a reaction's affinity may stand in for its summed balance).
1673
944
  _drivenKey() {
1674
- return this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}${this._noCurrent(t) ? '0' : ''}`).join();
1675
- }
1676
-
1677
- _noCurrent(t) {
1678
- return t.drive.kind === 'I' && !(t.drive.R > 0) && t.drive.src.value === 0;
945
+ return this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}${noCurrent(t) ? '0' : ''}`).join();
1679
946
  }
1680
947
 
1681
948
  // Held terminals take their sources' values at sourceTime (a floating one keeps its voltage,
@@ -2182,7 +1449,7 @@ export class Solver {
2182
1449
  res.fill(0);
2183
1450
  this.dtNow = dt;
2184
1451
  if (this.lin) this.lin.reset(1 / dt);
2185
- if (this.steady && dt === Infinity) this._snapFlat();
1452
+ if (this.steady && dt === Infinity) snapFlat(this);
2186
1453
  this.computeConcentrations();
2187
1454
 
2188
1455
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -2214,16 +1481,9 @@ export class Solver {
2214
1481
  // Terminals: ports (after every other term at their nodes, so a held level can read its
2215
1482
  // flux), then contacts, then the circuit rows of the floating ones.
2216
1483
  this._terminals(dt);
2217
- if (Number.isFinite(dt) && (this.constraints.length > 0 || this.timeLaws.length > 0)) this._circuitLaws(dt);
2218
- // The steady system's own rows (see _laws): levels held flat, conserved amounts, islands'
2219
- // balances, the last two kept aside for _solveBordered.
2220
- const steady = this.steady && dt === Infinity;
2221
- if (steady) this._flatRows();
2222
- if (steady && this.affinities.length > 0) this._applyAffinities();
2223
- if (steady && this.constraints.length > 0) this._applyConstraints();
2224
- if (steady && this.localConstraints.length > 0) this._applyLocalConstraints();
2225
- this.pinnedIslands = steady && this.islandsOn ? this._applyIslands() : [];
2226
- this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.affinities, ...this.pinnedIslands] : [];
1484
+ // The laws' rows (src/lawRows.js): a time step's circuit laws; a steady system's levels held
1485
+ // flat, affinities, conserved amounts, islands' balances, and the pins held aside of them.
1486
+ applyLaws(this, dt);
2227
1487
  this.transformed = this.combining && dt !== Infinity;
2228
1488
  if (this.transformed) this._chargeRows(dt);
2229
1489
  }
@@ -3082,6 +2342,7 @@ export class Solver {
3082
2342
  this.termC[k].fill(0);
3083
2343
  }
3084
2344
  this.termDI.fill(0);
2345
+ for (const x of this.termDX) x.fill(0);
3085
2346
  this.termI.fill(0);
3086
2347
  this.qg.fill(0);
3087
2348
  this.qgSlope.fill(0);
@@ -3281,7 +2542,7 @@ export class Solver {
3281
2542
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
3282
2543
  const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
3283
2544
  // Its current, the charging (Q − Q at the step's start)/dt; or, steady at no current, the
3284
- // charge it keeps in place of that (see _captureLaws).
2545
+ // charge it keeps in place of that (see captureLaws in src/lawRows.js).
3285
2546
  const rate = Number.isFinite(dt) ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
3286
2547
  let Q = 0, dQdV = 0;
3287
2548
  port.nodes.forEach((g, w) => {
@@ -3413,6 +2674,14 @@ export class Solver {
3413
2674
  // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
3414
2675
  // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
3415
2676
  const link = ct.phi, Dstart = sgn * this.contactDStart[side];
2677
+ // (In a steady solve, what a law in this circuit row counts of D_in: see applyConstraints in
2678
+ // src/lawRows.js.)
2679
+ const q = !dyn && this.steady && this.contactLaw[k] ? (this.contactQ[k] ??= { row: new Float64Array(this.sys.size + 1), D: 0, dV: 0 }) : null;
2680
+ if (q) {
2681
+ q.row.fill(0);
2682
+ q.D = 0;
2683
+ q.dV = 0;
2684
+ }
3416
2685
  if (this.phiUndefined[g]) {
3417
2686
  this.contactD[side] = 0;
3418
2687
  } else if (link.type === 'capacitive') {
@@ -3422,8 +2691,9 @@ export class Solver {
3422
2691
  this._j(b, 0, b, 0, Ac * link.C * VT);
3423
2692
  B[R[b * M]] += -Ac * link.C;
3424
2693
  this.contactD[side] = sgn * Din;
2694
+ 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
2695
  // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
3426
- // the charge it keeps in place of that (see _captureLaws).
2696
+ // the charge it keeps in place of that (see captureLaws in src/lawRows.js).
3427
2697
  const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
3428
2698
  if (rate > 0) {
3429
2699
  I += (Din - (dyn ? Dstart : this.chargeRefs.get(k))) * rate;
@@ -3434,6 +2704,10 @@ export class Solver {
3434
2704
  // The Poisson residual is the outside's charge, D_in.
3435
2705
  const Din = res[R[b * M]];
3436
2706
  this.contactD[side] = sgn * Din;
2707
+ if (q) {
2708
+ q.D = Din;
2709
+ this._captureRow(b, 0, 1, q.row);
2710
+ }
3437
2711
  if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
3438
2712
  I += (Din - Dstart) / dt;
3439
2713
  this._captureRow(b, 0, 1 / dt, C);
@@ -3470,92 +2744,6 @@ export class Solver {
3470
2744
 
3471
2745
 
3472
2746
 
3473
- // Steady solves: each flat stretch's level held at its contact's throughout (see
3474
- // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
3475
- // A flat stretch's level: its contact's, through the terminal k that holds it, or (reaching
3476
- // none) its first node's, which its amount fixes (that row is the amount's, a pin whose
3477
- // response moves the whole stretch: see _applyConstraints).
3478
- _flatLevel(st) {
3479
- const { M, u, uLo, z, VT, model } = this, i = st.species;
3480
- if (st.leftOpen || st.rightOpen) {
3481
- const side = st.leftOpen ? 'left' : 'right', link = model.contacts[side].species[i], k = side === 'left' ? 0 : 1;
3482
- return { k, hi: z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT, lo: 0 };
3483
- }
3484
- const o = this.blockOfNode[st.nodes[0]] * M + 1 + i;
3485
- return { k: -1, hi: u[o], lo: uLo[o] };
3486
- }
3487
-
3488
- // Each flat stretch's nodes set to its first node's level exactly (where Newton's damped
3489
- // updates have taken it, on the way to its contact's), so that it carries exactly no flux (the
3490
- // expm1 form of a zero difference). Flat only to round-off, a level moved by its contact had
3491
- // read as a current: a MOS capacitor's holes, their contact jumped to −6 V, as
3492
- // 3e-4 A/m² through a bulk that conducts ~1e10 S/m² (the true current was 3.5e-7).
3493
- _snapFlat() {
3494
- const { M, u, uLo } = this;
3495
- for (const st of this.flatStretches) {
3496
- const f = this.blockOfNode[st.nodes[0]] * M + 1 + st.species, hi = u[f], lo = uLo[f];
3497
- for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
3498
- const o = this.blockOfNode[g] * M + 1 + st.species;
3499
- if (this.loc[o] < 0) continue;
3500
- u[o] = hi;
3501
- uLo[o] = lo;
3502
- }
3503
- }
3504
- }
3505
-
3506
- _flatRows() {
3507
- const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
3508
- for (const st of this.flatStretches) {
3509
- const i = st.species, open = st.leftOpen || st.rightOpen;
3510
- const { k, hi, lo } = this._flatLevel(st);
3511
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
3512
- const b = this.blockOfNode[g], o = b * M + 1 + i;
3513
- if (this.loc[o] < 0) continue;
3514
- this._replaceRow(b, 1 + i);
3515
- for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
3516
- this._j(b, 1 + i, b, 1 + i, 1);
3517
- res[R[o]] = u[o] - hi + (uLo[o] - lo); // (the big terms first: hi + lo rounds to hi)
3518
- if (open && z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
3519
- }
3520
- // A face inside the stretch whose link holds the level continuous carries the species' flux
3521
- // as an unknown that only the edge balances, now replaced, determined: it's zero.
3522
- for (let f = st.regions[0]; f < st.regions[1]; f++) {
3523
- if (model.interfaces[f].links[i].type !== 'equilibrium') continue;
3524
- const b = this.blockOfFace[f], o = b * M + 1 + i;
3525
- if (this.loc[o] < 0) continue;
3526
- this._replaceRow(b, 1 + i);
3527
- for (const B of this.termB) B[R[o]] = 0;
3528
- this._j(b, 1 + i, b, 1 + i, 1);
3529
- res[R[o]] = u[o] + uLo[o];
3530
- }
3531
- }
3532
- }
3533
-
3534
- // Steady solves: each reaction that alone changes a combination (see _laws) held at its
3535
- // equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
3536
- // like an amount's).
3537
- _applyAffinities() {
3538
- const { M, u, uLo, res } = this, grid = this.model.grid;
3539
- for (const af of this.affinities) {
3540
- const b0 = Math.floor(af.row / M), r0 = af.row % M;
3541
- this._replaceRow(b0, r0);
3542
- this._j(b0, r0, b0, r0, 1);
3543
- res[this.rix[af.row]] = 0;
3544
- const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
3545
- let hi = af.rx.fixedA, lo = 0, len = 0;
3546
- for (const p of af.rx.part) {
3547
- const o = this.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
3548
- hi -= p.nu * u[o];
3549
- lo -= p.nu * uLo[o];
3550
- if (this.loc[o] < 0) continue;
3551
- af.idx[len] = this.rix[o];
3552
- af.w[len++] = -p.nu;
3553
- }
3554
- af.len = len;
3555
- af.res = hi + lo;
3556
- }
3557
- }
3558
-
3559
2747
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
3560
2748
  _replaceRow(b, rs) {
3561
2749
  const l = this.loc[b * this.M + rs];
@@ -3620,18 +2808,19 @@ export class Solver {
3620
2808
  // entries; where they're huge (an inversion layer's 0.1 nm cells, a conductance ~1e11 against
3621
2809
  // the layer's own storage and the trickle from the bulk), eliminating them loses the layer's
3622
2810
  // overall level to round-off, and Newton rattles there. delta and deltaV come in as the plain
3623
- // solve's and leave refined (in the transformed unknowns, as _solveBordered gives them); false
2811
+ // solve's and leave refined (in the transformed unknowns, as solveBordered gives them); false
3624
2812
  // if refining didn't help, leaving them as they came.
3625
2813
  _refine(dt, delta, deltaV) {
3626
2814
  const { res, termRes, termB, termC, termDI, rowDiv, floating: fl } = this;
3627
2815
  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) };
2816
+ 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
2817
  const restoreArrays = () => {
3630
2818
  res.set(save.res);
3631
2819
  termRes.set(save.termRes);
3632
2820
  save.termB.forEach((b, k) => termB[k].set(b));
3633
2821
  save.termC.forEach((c, k) => termC[k].set(c));
3634
2822
  termDI.set(save.termDI);
2823
+ save.termDX.forEach((x, k) => this.termDX[k].set(x));
3635
2824
  this.transformed = save.transformed;
3636
2825
  this.pins.forEach((p, a) => (p.res = save.pinRes[a]));
3637
2826
  };
@@ -3646,7 +2835,7 @@ export class Solver {
3646
2835
  rest.multiply(v, jv);
3647
2836
  if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
3648
2837
  lin.apply(v, jv, 1, 1 / dt);
3649
- // A flat stretch's rows hold each level to its first node's (see _flatRows).
2838
+ // A flat stretch's rows hold each level to its first node's (see flatRows in src/lawRows.js).
3650
2839
  for (const p of pins) {
3651
2840
  if (!p.col) continue;
3652
2841
  for (const o of p.col) if (o !== p.row && this.loc[o] >= 0) jv[R[o]] -= x[R[p.row]];
@@ -3659,6 +2848,7 @@ export class Solver {
3659
2848
  }
3660
2849
  for (let a = 0; a < K; a++) {
3661
2850
  let t = Dk[a] * x[N + a];
2851
+ for (let b = 0; b < K; b++) if (b !== a) t += this.termDX[fl[a]][fl[b]] * x[N + b];
3662
2852
  for (let j = 0; j < N; j++) t += Ck[a][j] * x[j];
3663
2853
  out[N + a] = t;
3664
2854
  }
@@ -3678,7 +2868,7 @@ export class Solver {
3678
2868
  p.res = rhs[R[p.row]];
3679
2869
  rhs[R[p.row]] = 0;
3680
2870
  }
3681
- this._solveBordered(rhs, out, outV, pins);
2871
+ solveBordered(this, rhs, out, outV, pins);
3682
2872
  const z = new Float64Array(N + K);
3683
2873
  for (let j = 0; j < N; j++) z[j] = out[j];
3684
2874
  for (let a = 0; a < K; a++) z[N + a] = outV[a];
@@ -3740,7 +2930,8 @@ export class Solver {
3740
2930
  const history = [], ownHistory = [];
3741
2931
  // Refined solves (see _refine), once the plain ones stall.
3742
2932
  let refine = false, refined = 0, noRefine = false;
3743
- // Islands (see _applyIslands): their summed rows go in only once the plain solves converge.
2933
+ // Islands (see applyIslands in src/lawRows.js): their summed rows go in only once the plain
2934
+ // solves converge.
3744
2935
  // Far from the solution, Newton exact along a weakly held level takes it by 1e5 thermal
3745
2936
  // units (a floating base, cold at bias), where the plain solves, which barely see that level,
3746
2937
  // converge. Converged, an island at the wrong level shows: it takes in a different current
@@ -3820,7 +3011,7 @@ export class Solver {
3820
3011
  try {
3821
3012
  const pins = this.pins;
3822
3013
  deltaV.fill(0);
3823
- this._solveBordered(res, delta, deltaV, pins);
3014
+ solveBordered(this, res, delta, deltaV, pins);
3824
3015
  if (refine && !this._refine(dt, delta, deltaV)) {
3825
3016
  refine = false; // (GMRES didn't converge, or strayed: the plain solves it is)
3826
3017
  noRefine = true;
@@ -4012,7 +3203,7 @@ export class Solver {
4012
3203
  this.sourceTime = this.steady ? this.steadyAt : tEnd;
4013
3204
  this.sourceBefore = !this.steady;
4014
3205
  this.combining = true;
4015
- this.lawForm = new Map(); // (see _circuitLaws)
3206
+ this.lawForm = new Map(); // (see circuitLaws in src/lawRows.js)
4016
3207
  let result;
4017
3208
  try {
4018
3209
  result = this.newton(dtEff, opts);
@@ -4029,7 +3220,8 @@ export class Solver {
4029
3220
  }
4030
3221
  if (!result.converged && Number.isFinite(dtEff) && [...this.lawForm.values()].some((v) => !v)) {
4031
3222
  // A terminal read by its flux whose response is lost to round-off (a floating gate's
4032
- // charging, below its metal's G Δη): once more, read by the change (see _circuitLaws).
3223
+ // charging, below its metal's G Δη): once more, read by the change (see circuitLaws in
3224
+ // src/lawRows.js).
4033
3225
  this.u.set(this.uPrev);
4034
3226
  this.uLo.set(this.uPrevLo);
4035
3227
  this.termV.set(this.termVPrev);
@@ -5057,7 +4249,8 @@ export class Solver {
5057
4249
  const last = this.model.regions.length - 1;
5058
4250
  const { n, cOld } = this, vol = this.model.grid.vol;
5059
4251
  // A stretch alone in what a terminal driven by a current feeds takes in what that current
5060
- // says, which the step kept exactly (see _circuitLaws); the flux through a metal is round-off.
4252
+ // says, which the step kept exactly (see circuitLaws in src/lawRows.js); the flux through a
4253
+ // metal is round-off.
5061
4254
  const driven = new Map();
5062
4255
  for (const cs of this.constraints) {
5063
4256
  if (cs.circuit < 0 || this.terms[cs.circuit].drive.kind !== 'I' || cs.parts.length !== 1 || cs.surface.length || cs.carriers.length) continue;
@@ -5102,7 +4295,7 @@ export class Solver {
5102
4295
  * Steady state: where a transient from the present state ends, found as the solution of the
5103
4296
  * steady equations (dt = ∞, no storage term), with no slow modes to wait out, however slow the
5104
4297
  * physics (e.g. exponentially scarce minority carriers). What the storage term held is held by
5105
- * the steady system's own rows instead (see _laws): each conserved amount in place of a balance
4298
+ * the steady system's own rows instead (see src/laws.js): each conserved amount in place of a balance
5106
4299
  * row that says nothing new, levels that carry nothing held flat, islands' summed balances.
5107
4300
  * Where Newton from here fails: continuation along the drives, then the pseudo-transient ramp.
5108
4301
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
@@ -5112,7 +4305,7 @@ export class Solver {
5112
4305
  solveSteady(opts = {}) {
5113
4306
  this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
5114
4307
  this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
5115
- this._captureLaws();
4308
+ captureLaws(this);
5116
4309
  this.steady = true; // (the steady system's rows; sources at the present time)
5117
4310
  this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
5118
4311
  try {
@@ -5131,7 +4324,7 @@ export class Solver {
5131
4324
  const I = sourceAt(this.terms[k].drive.src, this.time);
5132
4325
  if (this.terms[k].drive.kind !== 'I' || I === 0) continue;
5133
4326
  const laws = this.constraints.filter((cs) => cs.terminals.includes(k));
5134
- if (laws.length === 0 && !this._chargeOnly(k)) continue;
4327
+ if (laws.length === 0 && !chargeOnly(this, k)) continue;
5135
4328
  const what = laws.flatMap((cs) => cs.parts.map(({ stretch }) => {
5136
4329
  const st = this.stretches[stretch], [r0, r1] = st.regions;
5137
4330
  return `${species[st.species].name} in ${regions[r0].name}${r1 > r0 ? `…${regions[r1].name}` : ''}`;
@@ -5151,7 +4344,7 @@ export class Solver {
5151
4344
  _steadyFromHere(opts) {
5152
4345
  this.conditioning = null;
5153
4346
  this.computeConcentrations();
5154
- this._captureLocal(); // immobile combinations keep, node by node, what they hold now
4347
+ captureLocal(this); // immobile combinations keep, node by node, what they hold now
5155
4348
  this.sourceTime = this.time;
5156
4349
  // Continuation applies where the right terminal is held at a voltage, against a held left one.
5157
4350
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
@@ -5247,20 +4440,7 @@ export class Solver {
5247
4440
  // only charges a capacitance (so that its steady states are one per amount or charge, not one
5248
4441
  // per voltage).
5249
4442
  _bound(k) {
5250
- return this.constraints.some((cs) => cs.terminals.includes(k)) || this._chargeOnly(k);
5251
- }
5252
-
5253
- // Whether terminal k passes current only by charging a capacitance: no charged species through
5254
- // it (nor, a port, reactions), and a capacitance to charge (a gate's φ law, a port's own).
5255
- _chargeOnly(k) {
5256
- const t = this.terms[k], { species } = this.model;
5257
- const ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
5258
- if (t.kind === 'port') {
5259
- const p = this.model.ports[t.index];
5260
- return p.reactions.length === 0 && !ions(p.species) && !!p.capacitance;
5261
- }
5262
- const c = this.model.contacts[t.side];
5263
- return !ions(c.species) && (c.phi.type === 'capacitive' || c.phi.type === 'pinned');
4443
+ return this.constraints.some((cs) => cs.terminals.includes(k)) || chargeOnly(this, k);
5264
4444
  }
5265
4445
 
5266
4446
  // A cold steady solve with a terminal driven by a current, which can fail where the same state