driftlet 1.2.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,10 +495,11 @@ 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).
@@ -736,7 +739,8 @@ export class Solver {
736
739
  // a port). So Ag⁺ between silver electrodes is fed (Ag⁺ + e⁻ ⇌ Ag(s), the electrons fed by the
737
740
  // contacts), while Fe³⁺ and Fe²⁺ between platinum electrodes aren't (Fe³⁺ + e⁻ ⇌ Fe²⁺
738
741
  // conserves the iron, whatever the electrons do).
739
- this.moieties = this._conservedMoieties();
742
+ this.rough = this.stretches.map((st) => this._roughAmount(st));
743
+ this.moieties = conservedMoieties(this._structure());
740
744
  this.stretches.forEach((st, k) => {
741
745
  st.connected = st.contactFed || this.moieties.every((w) => w[k] === 0);
742
746
  });
@@ -753,7 +757,8 @@ export class Solver {
753
757
  // inversion electrons, which reach the back contact only through a bulk with ~1e3 of them per
754
758
  // cm³; blocked SO₄²⁻ driven from a zinc cathode's extended space charge, down to 1e-25 mol/m³
755
759
  // there, whose chain of conductances lost the system 14 digits). So steady solves hold it
756
- // 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.
757
762
  // (Not through flow or mixing, nor a concentrated material's cross-diffusion, where carrying
758
763
  // nothing isn't a level flat.)
759
764
  const plain = (st) => {
@@ -817,881 +822,39 @@ export class Solver {
817
822
  return t;
818
823
  }
819
824
 
820
- // Whether a stretch's species moves through it (mobile in every region, or a metal's carrier),
821
- // or stays still in every region (D = 0: trap states, conserved node by node).
822
- _moves(st) {
823
- const { regions, materials } = this.model;
824
- for (let q = st.regions[0]; q <= st.regions[1]; q++) {
825
- const mat = materials[regions[q].material];
826
- if (!(mat.conductor || mat.D[st.species] > 0)) return false;
827
- }
828
- return true;
829
- }
830
-
831
- _still(st) {
832
- const { regions, materials } = this.model;
833
- for (let q = st.regions[0]; q <= st.regions[1]; q++) {
834
- const mat = materials[regions[q].material];
835
- if (mat.conductor || mat.D[st.species] > 0) return false;
836
- }
837
- return true;
838
- }
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
-
854
- // Whether terminal k (a contact, or port k − 2) feeds stretch st: links its species there.
855
- _feeds(k, st) {
856
- return k === 0 ? st.leftOpen : k === 1 ? st.rightOpen : st.linked.includes(k - 2);
857
- }
858
-
859
- // What a steady solve keeps (under the terminals' present drives, so again after a change of
860
- // drive): each conserved amount, solved directly in place of one (redundant) balance row, and
861
- // 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.
862
827
  _laws() {
863
- const { model } = this, { regions, materials, grid } = model;
864
- const laws = this._conservedMoieties(true);
865
- for (const st of this.stretches) st.conserved = false;
866
- // Conserved amounts solved directly, each in place of one (redundant) balance row: a
867
- // spectator's own amount (a floating metal's: the charge on its faces), each conserved
868
- // combination of reacting stretches (the total iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose
869
- // weighted balances sum to zero at steady state, and what a terminal driven at no current
870
- // keeps behind it. A combination may take in still stretches (the electrons trap states hold),
871
- // their amounts summed like any other; one of still stretches alone conserves node by node
872
- // instead (below), and one of a stretch still in part only is left to giant time steps.
873
- this.constraints = [];
874
- const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
875
- // The terminals driven by a current that feed a stretch: a combination through one keeps what
876
- // the state holds when a solve begins (see _captureLaws), and has no steady state at any other
877
- // current.
878
- const driven = (st) => this.terms.flatMap((t, k) => (t.drive.kind === 'I' && this._feeds(k, st) ? [k] : []));
879
- const add = (parts, rowStretch, surface = [], circuit = -1, carriers = []) => {
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) }));
894
- let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
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;
896
- for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
897
- for (const p of parts) this.stretches[p.stretch].conserved = true;
898
- this.constraints.push({
899
- parts,
900
- surface,
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)
903
- terminals: [...new Set([...parts.flatMap((p) => driven(this.stretches[p.stretch])), ...carriers.map((p) => p.k)])],
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(),
905
- circuit, // (the terminal whose circuit row it stands in for, or −1 for a balance row's, `row`)
906
- lambda,
907
- row: circuit >= 0 ? -1 : this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
908
- idx: new Int32Array(nNodes * this.M),
909
- w: new Float64Array(nNodes * this.M),
910
- len: 0,
911
- res: 0,
912
- q: new Float64Array(this.sys.size + 1),
913
- });
914
- };
915
- this.stretches.forEach((st, k) => {
916
- if (!st.spectator || !this._moves(st)) return;
917
- add([{ stretch: k, w: 1 }], k);
918
- // Held flat (see _flatRows), it moves as a whole: the pin's response is the whole stretch's
919
- // shift, its amount saying how far.
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);
921
- });
922
- const S = this.stretches.length, T = this.terms.length, X = S + this.surfCols.length;
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);
929
- // The rows replaced: a moving stretch's balance (one of still stretches alone is kept node by
930
- // node, below), or the circuit row of a terminal driven at no current, which says nothing the
931
- // combination's balance rows don't (their weighted sum is that terminal's current, weighted
932
- // by w/z). Together they must be independent, each combination's weights over the rows
933
- // replaced a nonsingular matrix, or one balance is lost and another said twice (A + B = AB and
934
- // 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
935
- // A twice and C not at all, and the solve converged to a state that wasn't steady). So they're
936
- // chosen by elimination, each combination's weights reduced by the rows already chosen: a
937
- // circuit row where one is left, else the first stretch with weight 1, else the basis
938
- // vector's own (its weight is 1 there, and no other vector's is), else the largest.
939
- const rowOf = (k) => this.blockOfNode[this.stretches[k].nodes[0]] * this.M + 1 + this.stretches[k].species;
940
- const chosen = []; // (a column and the reduced weights, per row replaced: S + t for terminal t's)
941
- const spectators = new Set(this.constraints.map((cs) => cs.row));
942
- // What an electrode holds goes only in its terminal's circuit row (its charge depends on the
943
- // terminal's voltage, which a balance row's pin can't carry): the combinations through it come
944
- // first, so that the first takes that row, and the rest have its multiple taken off.
945
- const through = (w) => this.carriers.some((k, j) => w[X + j] !== 0);
946
- const held = new Map(); // (terminal → the combination in place of its circuit row)
947
- // Reactions' equilibria first. A face reaction that alone changes some combination (without
948
- // it, w would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
949
- // Ag⁺ + AgNO₃ where it also complexes) has that combination's summed balance rows equal to its
950
- // rate, w·ν times it, which a steady state sets to zero. The rate is a poor row: k₀ times a
951
- // function of the levels, beside diffusion's terms in the same balances. An Ag⁺/Ag couple
952
- // 1e-20 as fast as Fe³⁺/Fe²⁺ on the same platinum was lost to their round-off, the
953
- // open-circuit potential settling where silver wasn't (−0.4661 V, the joint equilibrium
954
- // −0.5004 V), and 1e-15 to 1e-18 failed. Its affinity says the same at any k₀ (each kinetic
955
- // form vanishes only there), so it stands in for one of those rows (see _applyAffinities),
956
- // chosen by the same elimination, and a stretch's: never a circuit row.
957
- // The slowest first, whose rate rows are the poorest, and each row picked by the reaction's
958
- // own terms, never by where its species are listed: the same rows in any listing. (Picked by
959
- // listing, an electrode's two couples took different rows with the species reversed, and
960
- // Newton converged in one listing and crawled in the other.)
961
- this.affinities = [];
962
- 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() })));
963
- faceRx.sort((a, b) => a.rx.k0 - b.rx.k0 || (a.key < b.key ? -1 : a.key > b.key ? 1 : a.f - b.f));
964
- for (const { f, rx } of faceRx) {
965
- const nu = new Float64Array(S);
966
- let inFlat = false;
967
- for (const p of rx.part) {
968
- const k = this.stretchOf[(f + p.side) * this.n + p.i];
969
- if (k < 0 || this.flatStretches.includes(this.stretches[k])) inFlat = true;
970
- else nu[k] += p.nu;
971
- }
972
- if (inFlat) continue; // (held flat at a contact's levels, in equilibrium already)
973
- // (Not one fed by a terminal driven by a current: its sum is the rate plus what that
974
- // passes. Silver's, e⁻ + Fe²⁺ with the electrons fed, or Ag⁺ + AgNO₃, which nothing is.
975
- // A current held at zero passes nothing: an electrode at open circuit feeding a closed
976
- // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
977
- const passes = (k) => !this._noCurrent(this.terms[k]);
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))));
979
- // Any of the combinations it alone changes will do, with any conserved amount added (whose
980
- // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
981
- // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
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;
988
- const kept = laws.filter((l) => !fed(l));
989
- const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
990
- for (const w of tries) {
991
- const v = new Float64Array(S + T);
992
- for (let k = 0; k < S; k++) {
993
- if (w[k] === 0) continue;
994
- const st = this.stretches[k];
995
- if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = w[k];
996
- if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = w[k] / this.z[st.species];
997
- }
998
- for (const [col, row] of chosen) {
999
- const fc = v[col];
1000
- if (fc !== 0) for (let j = 0; j < S + T; j++) v[j] -= fc * row[j];
1001
- }
1002
- const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
1003
- if (!(scale > 1e-9)) continue;
1004
- // (Never a metal's carriers, nor a stretch a terminal feeds, even at no current: their rows
1005
- // carry the terminal's current, and Pt's electrons standing for an iron couple's balance
1006
- // put the electrode at 602 V.)
1007
- 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]));
1008
- // (One of its own species first, then weight 1, the largest, and by name.)
1009
- const own = (k) => rx.part.some((p) => p.i === this.stretches[k].species && this.stretchOf[(f + p.side) * this.n + p.i] === k);
1010
- 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]];
1011
- 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; };
1012
- const col = [...Array(S).keys()].filter(ok).reduce((b, k) => (b < 0 || less(k, b) ? k : b), -1);
1013
- if (col < 0) continue;
1014
- chosen.push([col, v.map((x) => x / v[col])]);
1015
- this.affinities.push({ rx, f, row: rowOf(col), idx: new Int32Array(rx.part.length), w: new Float64Array(rx.part.length), len: 0, res: 0 });
1016
- break;
1017
- }
1018
- }
1019
- this.contactQ = [null, null];
1020
- for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
1021
- for (const [j, k] of this.carriers.entries()) {
1022
- const c = held.get(k);
1023
- if (w[X + j] === 0 || !c) continue;
1024
- const f = w[X + j] / c[X + j], own = w.own;
1025
- w = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
1026
- w.own = own;
1027
- }
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] }));
1030
- const carriers = this.carriers.flatMap((k, j) => (w[X + j] !== 0 ? [{ k, w: w[X + j] }] : []));
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;
1035
- if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
1036
- if (parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
1037
- const v = new Float64Array(S + T);
1038
- for (const { stretch: k, w: wk } of parts) {
1039
- const st = this.stretches[k];
1040
- if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = wk;
1041
- if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = wk / this.z[st.species];
1042
- }
1043
- for (const { k, w: wk } of carriers) v[S + k] = wk / zc(k);
1044
- for (const [col, row] of chosen) {
1045
- const f = v[col];
1046
- if (f !== 0) for (let j = 0; j < S + T; j++) v[j] -= f * row[j];
1047
- }
1048
- const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
1049
- if (!(scale > 1e-9)) continue;
1050
- const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
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);
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.)
1055
- chosen.push([col, v.map((x) => x / v[col])]);
1056
- if (circuitOf(col) >= 0) held.set(circuitOf(col), w);
1057
- add(parts, circuitOf(col) < 0 ? col : parts[0]?.stretch, surface, circuitOf(col), carriers);
1058
- }
1059
- this.contactLaw = [0, 1].map((k) => this.constraints.some((cs) => cs.contacts.some((c) => c.t === k)));
1060
- // A combination that nothing carries anywhere is conserved node by node: of still stretches
1061
- // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
1062
- // that only turn into each other). At each node its weighted sum stays what it was, a row local
1063
- // to that node's block, in place of one of their balances.
1064
- this.localConstraints = [];
1065
- const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
1066
- for (const w of laws) {
1067
- const parts = [...w.keys()].filter((k) => w[k] !== 0);
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];
1082
- this.localConstraints.push({
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,
1086
- reference: new Float64Array(sets[0].length),
1087
- });
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
- }
1109
- // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
1110
- // contact, port or reaction) holds its level only through its faces' fluxes, which can be
1111
- // ~1e-24 of its own conduction (a tiny conductance at the face, or a neighbour that barely
1112
- // conducts): eliminated, the level is lost to round-off, and Newton settles at a wrong one with
1113
- // nothing to show for it. In steady solves the piece's balance summed over its nodes, where
1114
- // the internal fluxes cancel exactly and the faces' alone are left, replaces its first node's
1115
- // (see _applyIslands), once the plain solves converge (see newton).
1116
- 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)
1117
839
  this.pinnedIslands = [];
1118
840
  this.islandsOn = false; // (newton() turns them on, see there)
1119
841
  this.pins = [];
1120
- const used = new Set([...this.constraints.map((cs) => cs.row).filter((o) => o >= 0), ...this.affinities.map((af) => af.row)]);
1121
- for (const st of this.stretches) {
1122
- const i = st.species, [s0, s1] = st.regions;
1123
- if (!st.mobile || conductor(st)) continue;
1124
- for (let q0 = s0, q1 = s0; q1 <= s1; q1++) {
1125
- const p0 = q0;
1126
- q0 = q1 + 1;
1127
- if (p0 === s0 && q1 === s1) continue; // (uncut)
1128
- if ((p0 === s0 && st.leftOpen) || (q1 === s1 && st.rightOpen)) continue;
1129
- const inside = (q) => q >= p0 && q <= q1;
1130
- 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;
1131
- let reactive = false;
1132
- for (let q = p0; q <= q1; q++) {
1133
- for (const rx of model.reactions) if (rx.kf[regions[q].material] > 0 && [...rx.reactants, ...rx.products].some((p) => p.i === i)) reactive = true;
1134
- }
1135
- for (let f = Math.max(0, p0 - 1); f <= Math.min(model.interfaces.length - 1, q1); f++) {
1136
- for (const rx of model.interfaces[f].reactions) if (rx.part.some((p) => p.i === i && inside(f + p.side))) reactive = true;
1137
- }
1138
- const row = this.blockOfNode[grid.regionStart[p0]] * this.M + 1 + i;
1139
- if (reactive || used.has(row)) continue;
1140
- used.add(row);
1141
- // The faces' fluxes into the piece: −A_f u at its left face, +A_f u at its right. Across
1142
- // a face that holds μ̄ level, u is set only by the edge balances, and eliminated, it can
1143
- // come out of the piece's own (G Δη, its two η all but equal: a neighbour 1e20 times
1144
- // less conductive, and the piece's level was seen through one face of two, and Newton
1145
- // swung it back and forth across both). There the outside edge node's balance is added:
1146
- // u cancels, and the outside's first segment carries the flux instead.
1147
- const faces = [], outside = [];
1148
- if (p0 > s0) faces.push([p0 - 1, -1, grid.regionEnd[p0 - 1]]);
1149
- if (q1 < s1) faces.push([q1, 1, grid.regionStart[q1 + 1]]);
1150
- for (const [f, , g] of faces) {
1151
- if (model.interfaces[f].links[i].type !== 'equilibrium' || g === 0 || g === this.nNodes - 1) continue;
1152
- if (model.ports.some((port) => port.nodes.includes(g))) continue;
1153
- outside.push(this.blockOfNode[g] * this.M + 1 + i);
1154
- }
1155
- this.islands.push({
1156
- stretch: st,
1157
- row,
1158
- flux: faces.map(([f]) => this.blockOfFace[f] * this.M + 1 + i),
1159
- weight: faces.map(([f, sg]) => sg * grid.area[grid.regionEnd[f]]),
1160
- outside,
1161
- idx: new Int32Array(faces.length + 3 * this.M * outside.length),
1162
- w: new Float64Array(faces.length + 3 * this.M * outside.length),
1163
- len: 0,
1164
- res: 0,
1165
- });
1166
- }
1167
- }
1168
- for (const isl of this.islands) isl.outside = isl.outside.filter((o) => !used.has(o));
1169
-
1170
- // Time steps only: a terminal behind a resistance that alone feeds a combination reads its
1171
- // current by that combination's change too (see _circuitLaws), its combinations found with it
1172
- // counted as driven by a current. (Not in the steady system, where it holds its level.)
1173
- this.timeLaws = [];
1174
- const behind = this.floating.filter((k) => this.terms[k].drive.kind !== 'I');
1175
- if (behind.length > 0) {
1176
- const steadyCarriers = this.carriers, more = this._conservedMoieties(true, behind), X2 = S + this.surfCols.length;
1177
- for (const w of more) {
1178
- const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
1179
- if (parts.length === 0 || parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
1180
- const surface = [...w.keys()].filter((k) => k >= S && k < X2 && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
1181
- const carriers = this.carriers.flatMap((k, j) => (w[X2 + j] !== 0 ? [{ k, w: w[X2 + j] }] : []));
1182
- // Fed by one terminal behind a resistance and nothing else that passes current.
1183
- const fedBy = new Set([...carriers.map((p) => p.k), ...parts.flatMap((p) => this.floating.filter((k) => this._feeds(k, this.stretches[p.stretch])))]);
1184
- if (fedBy.size !== 1) continue;
1185
- const [k] = fedBy;
1186
- if (!behind.includes(k) || this.timeLaws.some((cs) => cs.circuit === k)) continue;
1187
- const zc = k >= 2 ? this._carrierZ(k) : 0;
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);
1189
- if (!lambda) continue;
1190
- let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
1191
- for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
1192
- for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
1193
- 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 });
1194
- }
1195
- this.carriers = steadyCarriers;
1196
- }
1197
- }
1198
-
1199
- // What each combination fed by a terminal driven at no current holds now, which a steady solve
1200
- // keeps: a closed battery at open circuit stays at the charge it had.
1201
- _captureLaws() {
1202
- // A terminal at no current that passes current only by charging a capacitance keeps its charge.
1203
- this.chargeRefs.clear();
1204
- const charged = this.floating.filter((k) => this.terms[k].drive.kind === 'I' && this._chargeOnly(k));
1205
- if (charged.length > 0) {
1206
- this._assembleBookkeeping(Infinity);
1207
- for (const k of charged) this.chargeRefs.set(k, k >= 2 ? this.portQ[k - 2] : k === 0 ? this.contactD.left : -this.contactD.right);
1208
- }
1209
- this.lawRefs.clear();
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);
1211
- for (const cs of this.constraints) {
1212
- if (cs.terminals.length === 0) continue;
1213
- let a = 0;
1214
- for (const { stretch, w } of cs.parts) a += w * this.amount(this.stretches[stretch]);
1215
- for (const { col, w } of cs.surface) a += w * this.surfaceAmount(col);
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;
1218
- this.lawRefs.set(cs.key, a);
1219
- }
1220
- }
1221
-
1222
- // Each local constraint's weighted sum at each node, as the state holds it now: what a steady
1223
- // solve keeps.
1224
- _captureLocal() {
1225
- const { n, c, th, nSurf } = this;
1226
- for (const lc of this.localConstraints) {
1227
- lc.nodes.forEach((g, j) => {
1228
- let t = 0;
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
- }
1233
- lc.reference[j] = t;
1234
- });
1235
- }
1236
- }
1237
-
1238
- // The islands' summed balances (steady solves only): Σ A_f u over the faces into each, its
1239
- // first node's row pinned in the factorised matrix and the sum bordered (_solveBordered) like a
1240
- // conserved amount's. The pieces of a stretch held flat by its contact are left as they are.
1241
- _applyIslands() {
1242
- const { u, res } = this, R = this.rix, out = [];
1243
- for (const isl of this.islands) {
1244
- if (this.flatStretches.includes(isl.stretch)) continue;
1245
- const b0 = Math.floor(isl.row / this.M), r0 = isl.row % this.M;
1246
- if (this.loc[isl.row] < 0) continue;
1247
- // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
1248
- // exactly where they meet), then the nonzeros kept.
1249
- const acc = this.islandRow ?? (this.islandRow = new Float64Array(this.sys.size + 1));
1250
- const cols = [];
1251
- const add = (col, v) => {
1252
- if (acc[col] === 0) cols.push(col);
1253
- acc[col] += v;
1254
- };
1255
- let t = 0;
1256
- isl.flux.forEach((o, j) => {
1257
- if (this.loc[o] < 0) return;
1258
- t += isl.weight[j] * u[o];
1259
- add(R[o], isl.weight[j]);
1260
- });
1261
- for (const o of isl.outside) {
1262
- if (this.loc[o] < 0) continue;
1263
- t += res[R[o]];
1264
- this._rowInto(o, add);
1265
- }
1266
- let len = 0;
1267
- for (const col of cols) {
1268
- if (col < this.sys.size && acc[col] !== 0) {
1269
- isl.idx[len] = col;
1270
- isl.w[len++] = acc[col];
1271
- }
1272
- acc[col] = 0;
1273
- }
1274
- isl.len = len;
1275
- isl.res = t;
1276
- this._replaceRow(b0, r0);
1277
- for (const B of this.termB) B[R[isl.row]] = 0;
1278
- this._j(b0, r0, b0, r0, 1);
1279
- res[R[isl.row]] = 0;
1280
- out.push(isl);
1281
- }
1282
- return out;
1283
842
  }
1284
843
 
1285
- // Row o's Jacobian entries, as add(compact column, value): the assembled ones, and the dilute
1286
- // kernels' kept aside in difference form (see _assembleDifference) when they are.
1287
- _rowInto(o, add) {
1288
- const { sys, M, nB } = this, b = Math.floor(o / M), l = this.loc[o];
1289
- const { A, B, C, sizes, offA, offB, offC, offX } = sys, m = sizes[b];
1290
- 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]);
1291
- 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]);
1292
- 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]);
1293
- if (this.lin) {
1294
- const into = this.linRow ?? (this.linRow = new Float64Array(sys.size + 1));
1295
- this.lin.capture(this.rix[o], 1, 0, into);
1296
- for (let k = 0; k < sys.size; k++) {
1297
- if (into[k] !== 0) add(k, into[k]);
1298
- into[k] = 0;
1299
- }
1300
- }
1301
- }
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
-
1311
- // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
1312
- _applyLocalConstraints() {
1313
- const { n, M, c, th, nSurf, res, dA: d } = this;
1314
- for (const lc of this.localConstraints) {
1315
- lc.nodes.forEach((g, j) => {
1316
- const b = this.blockOfNode[g];
1317
- if (this.loc[b * M + lc.row] < 0) return;
1318
- this._replaceRow(b, lc.row);
1319
- let t = 0;
1320
- for (const { i, q, k, w } of lc.parts) {
1321
- if (i !== undefined) {
1322
- t += w * c[g * n + i];
1323
- this._dc(g, i, d);
1324
- for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1325
- continue;
1326
- }
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]));
1332
- }
1333
- res[this.rix[b * M + lc.row]] = t - lc.reference[j];
1334
- });
1335
- }
1336
- }
1337
-
1338
- // A basis of the conserved combinations of stretch amounts: the null space of the
1339
- // stoichiometry (a row per reaction, over the stretches it touches) together with what the
1340
- // terminals feed. A terminal held at a voltage (or behind a resistance, which holds it at
1341
- // steady state) feeds each stretch it links freely: a unit row. With `drives`, one driven by a
1342
- // current feeds its charged species only as that current allows, in proportion to their
1343
- // charges, so a combination weighted by z there is conserved while it passes none: a battery's
1344
- // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
1345
- // elimination will do.
1346
- _conservedMoieties(drives = false, alsoCurrent = [], skip = null) {
1347
- const { model, n, z } = this, S = this.stretches.length;
1348
- // With `drives`, an electrode port driven by a current whose reactions take its carrier from
1349
- // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
1350
- // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
1351
- // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
1352
- const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
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] : [])) : [];
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;
1359
- // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1360
- // its own rows, over the columns, are handed back as `basis.skipped`.)
1361
- const rows = [], skipped = [];
1362
- model.interfaces.forEach((itf, f) => {
1363
- for (const rx of itf.reactions) {
1364
- const row = new Float64Array(C);
1365
- for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
1366
- (rx === skip ? skipped : rows).push(row);
1367
- }
1368
- });
1369
- // An electrode surface's species are columns too, after the stretches (S + j for surface
1370
- // column j), so a combination that passes through them (A⁺ + e⁻ = S, S + e⁻ = B⁻) is conserved
1371
- // with what the surface holds.
1372
- model.ports.forEach((port, k) => {
1373
- for (const rx of port.reactions) {
1374
- const row = new Float64Array(C); // (the electrode's carrier is outside the device)
1375
- if (rx === skip) skipped.push(row);
1376
- for (const p of rx.part) {
1377
- if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
1378
- else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
1379
- else if (this.carriers.includes(k + 2)) row[X + this.carriers.indexOf(k + 2)] += p.nu;
1380
- }
1381
- if (rx !== skip) rows.push(row);
1382
- }
1383
- });
1384
- model.regions.forEach((reg, q) => {
1385
- for (const rx of model.reactions) {
1386
- if (!(rx.kf[reg.material] > 0)) continue;
1387
- const row = new Float64Array(C);
1388
- for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
1389
- for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
1390
- (rx === skip ? skipped : rows).push(row);
1391
- }
1392
- });
1393
- const unit = (k) => {
1394
- const row = new Float64Array(C);
1395
- row[k] = 1;
1396
- rows.push(row);
1397
- };
1398
- this.terms.forEach((t, k) => {
1399
- const fed = this.stretches.flatMap((st, j) => (this._feeds(k, st) ? [j] : []));
1400
- if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
1401
- const charged = fed.filter((j) => z[this.stretches[j].species] !== 0).map((j) => [j, z[this.stretches[j].species]]);
1402
- if (this.carriers.includes(k)) charged.push([X + this.carriers.indexOf(k), this._carrierZ(k)]);
1403
- fed.filter((j) => z[this.stretches[j].species] === 0).forEach(unit);
1404
- for (let a = 1; a < charged.length; a++) {
1405
- const row = new Float64Array(C);
1406
- row[charged[0][0]] = 1 / charged[0][1];
1407
- row[charged[a][0]] = -1 / charged[a][1];
1408
- rows.push(row);
1409
- }
1410
- });
1411
- // Reduced row echelon form; the free columns give the null space. Immobile stretches' columns
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
- });
1428
- const pivots = [];
1429
- let r = 0;
1430
- for (const col of order) {
1431
- if (r >= rows.length) break;
1432
- let best = r;
1433
- for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
1434
- if (Math.abs(rows[best][col]) < 1e-9) continue;
1435
- [rows[r], rows[best]] = [rows[best], rows[r]];
1436
- const pr = rows[r], pv = pr[col];
1437
- for (let j = 0; j < C; j++) pr[j] /= pv;
1438
- for (let i = 0; i < rows.length; i++) {
1439
- if (i === r || rows[i][col] === 0) continue;
1440
- const fct = rows[i][col];
1441
- for (let j = 0; j < C; j++) rows[i][j] -= fct * pr[j];
1442
- }
1443
- pivots.push(col);
1444
- r++;
1445
- }
1446
- const isPivot = new Uint8Array(C);
1447
- for (const col of pivots) isPivot[col] = 1;
1448
- const basis = [];
1449
- for (let free = 0; free < C; free++) {
1450
- if (isPivot[free]) continue;
1451
- const w = new Float64Array(C);
1452
- w[free] = 1;
1453
- pivots.forEach((col, i) => {
1454
- const v = -rows[i][free];
1455
- w[col] = Math.abs(v) < 1e-9 ? 0 : v;
1456
- });
1457
- w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
1458
- basis.push(w);
1459
- }
1460
- basis.skipped = skipped;
1461
- return basis;
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 };
1462
849
  }
1463
850
 
1464
- // Every stretch either reaches a contact or is a mobile spectator: the steady equations can
1465
- // 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.
1466
854
  _directSteady() {
1467
- return this.stretches.every((st) => st.connected || st.conserved);
1468
- }
1469
-
1470
- // Conservation rows for the spectators (steady solves only): Σ v c_i = amount over the
1471
- // stretch replaces the balance row of its first node, which in steady state is the negative
1472
- // sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
1473
- // (identity row) there instead, and _solveBordered restores the constraint.
1474
- _applyConstraints() {
1475
- const { M, res } = this;
1476
- for (const cs of this.constraints) {
1477
- const { amount, reference, dV } = this._lawAmount(cs);
1478
- cs.res = amount - (cs.terminals.length > 0 ? (this.lawRefs.get(cs.key) ?? amount) : reference);
1479
- if (cs.circuit >= 0) {
1480
- // In place of the terminal's circuit row (its I = 0).
1481
- const C = this.termC[cs.circuit];
1482
- C.fill(0);
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
- }
1491
- this.termRes[cs.circuit] = cs.res;
1492
- this.termDI[cs.circuit] = dV[cs.circuit];
1493
- dV.forEach((x, k) => k !== cs.circuit && (this.termDX[cs.circuit][k] = x));
1494
- continue;
1495
- }
1496
- const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
1497
- this._replaceRow(b0, r0);
1498
- this._j(b0, r0, b0, r0, 1);
1499
- res[this.rix[cs.row]] = 0;
1500
- }
1501
- }
1502
-
1503
- // A conserved combination's amount as the state holds it (and its reference, closed), with its
1504
- // derivatives in cs.idx/w (and dV, by the voltage of a terminal whose electrode's charge it
1505
- // counts).
1506
- _lawAmount(cs) {
1507
- const { n, M, c, dA: d } = this;
1508
- const vol = this.model.grid.vol;
1509
- let amount = 0, reference = 0, len = 0, gross = 0;
1510
- for (const { stretch, w } of cs.parts) {
1511
- const st = this.stretches[stretch], i = st.species;
1512
- if (cs.terminals.length === 0) reference += w * this.referenceAmounts[stretch];
1513
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
1514
- amount += w * vol[g] * c[g * n + i];
1515
- gross += Math.abs(w * vol[g] * c[g * n + i]);
1516
- if (this.nodeConductor[g] >= 0) {
1517
- // A metal's carriers: the sheet at a charged face, vol·c = ±D_f·A/(zF), held by the
1518
- // face's displacement.
1519
- const f = this.sheetFace[g];
1520
- if (f >= 0 && this.loc[this.blockOfFace[f] * M] >= 0) {
1521
- cs.idx[len] = this.rix[this.blockOfFace[f] * M];
1522
- cs.w[len++] = (w * this.sheetSign[g] * this.model.grid.area[g]) / (this.z[i] * FARADAY);
1523
- }
1524
- continue;
1525
- }
1526
- this._dc(g, i, d);
1527
- const b = this.blockOfNode[g];
1528
- for (let r = 0; r < M; r++) {
1529
- if (d[r] === 0 || this.loc[b * M + r] < 0) continue;
1530
- cs.idx[len] = this.rix[b * M + r];
1531
- cs.w[len++] = w * vol[g] * d[r];
1532
- }
1533
- }
1534
- }
1535
- // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
1536
- for (const { col, w } of cs.surface) {
1537
- const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
1538
- if (cs.terminals.length === 0) reference += w * this.surfaceRef[col];
1539
- port.nodes.forEach((g, j) => {
1540
- const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
1541
- amount += f * tq;
1542
- gross += Math.abs(f * tq);
1543
- for (let x = 0; x < ns; x++) {
1544
- cs.idx[len] = this.rix[b * M + 1 + n + x];
1545
- cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
1546
- }
1547
- });
1548
- }
1549
- // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
1550
- // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as _portCapacitance has it).
1551
- const dV = cs.dV ?? (cs.dV = new Float64Array(this.terms.length));
1552
- dV.fill(0);
1553
- for (const { k, w } of cs.carriers) {
1554
- const port = this.model.ports[k - 2];
1555
- if (!port.capacitance) continue;
1556
- const f = w / (this._carrierZ(k) * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k];
1557
- const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
1558
- port.nodes.forEach((g, j) => {
1559
- if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
1560
- const b = this.blockOfNode[g], s = vol[g] * this.portArea[k - 2][j] * Cs;
1561
- amount += f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M]));
1562
- gross += Math.abs(f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M])));
1563
- dV[k] += f * s;
1564
- cs.idx[len] = this.rix[b * M];
1565
- cs.w[len++] = -f * s * this.VT;
1566
- });
1567
- }
1568
- cs.len = len;
1569
- return { amount, reference, dV, gross }; // (dV by terminal)
855
+ return this.unkept.length === 0 && this.stretches.every((st) => st.connected || st.conserved);
1570
856
  }
1571
857
 
1572
- // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
1573
- _lawStart(cs) {
1574
- const { n, nSurf } = this, vol = this.model.grid.vol;
1575
- let a = 0;
1576
- for (const { stretch, w } of cs.parts) {
1577
- const st = this.stretches[stretch], i = st.species;
1578
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * this.cOld[g * n + i];
1579
- }
1580
- for (const { col, w } of cs.surface) {
1581
- const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity;
1582
- port.nodes.forEach((g, j) => (a += w * G * vol[g] * this.portArea[k][j] * this.thOld[g * nSurf + q]));
1583
- }
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);
1585
- return a;
1586
- }
1587
-
1588
- // Time steps: a terminal driven by a current that alone feeds a conserved combination can read
1589
- // its current as the combination's change, F (S − S*)/(λ dt), which the step's balance rows sum
1590
- // to exactly, rather than as the flux through it. Both are exact; they differ in round-off and
1591
- // in what Newton can see. Through a metal, the flux is G Δη, its two η all but equal: a floating
1592
- // gate's charging current fell below that at steps of ~0.03 s, and the step came out singular.
1593
- // Over long steps the change is the better kept (a closed host at open circuit, read by its
1594
- // flux, drifted from its charge by 3 mV over 5e11 s, and its steps failed by 4e12 s). Over
1595
- // short ones it's the amount's round-off over dt (a battery's cathode holds 0.04 mol/m² of
1596
- // electrons: 1e-6 A/m² at steps of 1e-6 s, enough to reject steps at tol 1e-8), where the flux,
1597
- // its levels carried in two words (u + uLo), is far cleaner than ε G. So a step reads the change
1598
- // where that's ε·F·S/(λ dt) below 1e-6 of the flux's ε G, as its start has them, keeps to it
1599
- // throughout, and where a step read by the flux fails, it's tried once more read by the change
1600
- // (see step).
1601
- _circuitLaws(dt) {
1602
- for (const cs of [...this.constraints, ...this.timeLaws]) {
1603
- const k = cs.circuit;
1604
- if (k < 0 || !this.floating.includes(k) || cs.contacts?.length > 0) continue; // (a contact's charge: its own rows)
1605
- const { amount, dV, gross } = this._lawAmount(cs), f = FARADAY / (cs.lambda * dt), C = this.termC[k];
1606
- if (!this.lawForm.has(cs)) {
1607
- let G = 0;
1608
- for (let j = 0; j < C.length; j++) G = Math.max(G, Math.abs(C[j]));
1609
- this.lawForm.set(cs, Math.abs(f) * gross < 1e-6 * G);
1610
- }
1611
- if (!this.lawForm.get(cs)) continue;
1612
- C.fill(0);
1613
- for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
1614
- const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1615
- this.termRes[k] = f * (amount - this._lawStart(cs)) - (d.kind === 'I' ? src : (src - this.termV[k]) / 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));
1618
- }
1619
- }
1620
-
1621
- // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
1622
- // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
1623
- // the rows pinned in T: the spectators' conserved amounts (see _applyConstraints) and the
1624
- // islands' summed balances (see _applyIslands). By low-rank
1625
- // updates of T (Woodbury): δ = y + Σ_q Q_q μ_q − Σ_k X_k δV_k with y = T⁻¹ rhs, Q_q = T⁻¹ e_q
1626
- // (the response to a unit pin) and X_k = T⁻¹ B_k (to a unit change of V_k), then a small dense
1627
- // system for the μ (pins) and δV (terminals):
1628
- // pin q: Σ (W_q·Q_q′) μ_q′ − Σ (W_q·X_k) δV_k = res_q − W_q·y
1629
- // terminal k: Σ (C_k·Q_q) μ_q + Σ (δ_kk′ ∂I_k/∂V_k − C_k·X_k′) δV_k′ = res_k − C_k·y
1630
- _solveBordered(rhs, delta, deltaV, pins) {
1631
- const N = this.sys.size, fl = this.floating, P = pins.length, K = fl.length, S = P + K;
1632
- this._solveLinear(rhs, delta);
1633
- if (S === 0) return;
1634
- const e = this.dWork ?? (this.dWork = new Float64Array(N + 1));
1635
- const cols = this.borderCols ?? (this.borderCols = []);
1636
- for (let a = 0; a < S; a++) {
1637
- if (!cols[a]) cols[a] = new Float64Array(N + 1);
1638
- if (a < P) {
1639
- // The response to a unit pin, or where the pin moves a flat stretch, to its shift.
1640
- const col = pins[a].col ?? [pins[a].row];
1641
- for (const o of col) e[this.rix[o]] = 1;
1642
- this._solveLinear(e, cols[a]);
1643
- for (const o of col) e[this.rix[o]] = 0;
1644
- } else this._solveLinear(this.termB[fl[a - P]], cols[a]);
1645
- }
1646
- // Row a of the small system, as a dot product with a compact vector (sparse for pins).
1647
- const dot = (a, v) => {
1648
- let t = 0;
1649
- if (a < P) {
1650
- const p = pins[a];
1651
- for (let j = 0; j < p.len; j++) t += p.w[j] * v[p.idx[j]];
1652
- } else {
1653
- const C = this.termC[fl[a - P]];
1654
- for (let j = 0; j < N; j++) t += C[j] * v[j];
1655
- }
1656
- return t;
1657
- };
1658
- const A = Array.from({ length: S }, () => new Float64Array(S + 1));
1659
- for (let a = 0; a < S; a++) {
1660
- for (let b = 0; b < S; b++) A[a][b] = (b < P ? 1 : -1) * dot(a, cols[b]);
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
- }
1665
- A[a][S] = (a < P ? pins[a].res : this.termRes[fl[a - P]]) - dot(a, delta);
1666
- let mx = 0;
1667
- for (let b = 0; b < S; b++) mx = Math.max(mx, Math.abs(A[a][b]));
1668
- if (mx > 0) for (let b = 0; b <= S; b++) A[a][b] /= mx;
1669
- }
1670
- // Small dense solve with partial pivoting.
1671
- for (let col = 0; col < S; col++) {
1672
- let p = col;
1673
- for (let r = col + 1; r < S; r++) if (Math.abs(A[r][col]) > Math.abs(A[p][col])) p = r;
1674
- [A[col], A[p]] = [A[p], A[col]];
1675
- if (A[col][col] === 0) throw new SolverError('the bordered system is singular: a conserved amount or a floating terminal is not determined');
1676
- for (let r = col + 1; r < S; r++) {
1677
- const f = A[r][col] / A[col][col];
1678
- for (let k = col; k <= S; k++) A[r][k] -= f * A[col][k];
1679
- }
1680
- }
1681
- const x = new Float64Array(S);
1682
- for (let r = S - 1; r >= 0; r--) {
1683
- let v = A[r][S];
1684
- for (let k = r + 1; k < S; k++) v -= A[r][k] * x[k];
1685
- x[r] = v / A[r][r];
1686
- }
1687
- for (let a = 0; a < S; a++) {
1688
- const sa = a < P ? x[a] : -x[a], col = cols[a];
1689
- for (let j = 0; j < N; j++) delta[j] += sa * col[j];
1690
- if (a >= P) deltaV[a - P] = x[a];
1691
- }
1692
- }
1693
-
1694
-
1695
858
  // An electrode surface at its starting coverages at node g: η = μ°/RT + ln(θ/θ₀).
1696
859
  _surfaceStart(g) {
1697
860
  const { n, M, u, model } = this, surf = model.ports[this.surfPort[g]].surface, b = this.blockOfNode[g];
@@ -1724,7 +887,7 @@ export class Solver {
1724
887
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
1725
888
  if (this.floating.length !== before.length) this.deltaV = null;
1726
889
  // A terminal newly driven by a current (or no longer) can close a combination behind it.
1727
- // (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)
1728
891
  this.drivenKey = this._drivenKey();
1729
892
  if (this.drivenKey !== driven) this._laws();
1730
893
  this._refreshSources();
@@ -1732,17 +895,17 @@ export class Solver {
1732
895
  }
1733
896
 
1734
897
  // After a steady solve: each reaction that alone changes some combination nothing else feeds
1735
- // (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
1736
899
  // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
1737
900
  // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
1738
901
  // port's slow couple at its starting composition, each converged without a word. (A face
1739
902
  // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
1740
903
  stuckReactions() {
1741
904
  const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
1742
- const passes = (k) => !this._noCurrent(this.terms[k]);
1743
- 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])))));
1744
907
  const needed = (rx) => {
1745
- const basis = this._conservedMoieties(true, [], rx);
908
+ const basis = conservedMoieties(this._structure(), { drives: true, skip: rx });
1746
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));
1747
910
  };
1748
911
  const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
@@ -1775,15 +938,11 @@ export class Solver {
1775
938
  return out;
1776
939
  }
1777
940
 
1778
- // 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,
1779
942
  // behind a resistance or not, and a current that's held at zero throughout (which feeds a
1780
943
  // combination nothing, so a reaction's affinity may stand in for its summed balance).
1781
944
  _drivenKey() {
1782
- return this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}${this._noCurrent(t) ? '0' : ''}`).join();
1783
- }
1784
-
1785
- _noCurrent(t) {
1786
- 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();
1787
946
  }
1788
947
 
1789
948
  // Held terminals take their sources' values at sourceTime (a floating one keeps its voltage,
@@ -2290,7 +1449,7 @@ export class Solver {
2290
1449
  res.fill(0);
2291
1450
  this.dtNow = dt;
2292
1451
  if (this.lin) this.lin.reset(1 / dt);
2293
- if (this.steady && dt === Infinity) this._snapFlat();
1452
+ if (this.steady && dt === Infinity) snapFlat(this);
2294
1453
  this.computeConcentrations();
2295
1454
 
2296
1455
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -2322,16 +1481,9 @@ export class Solver {
2322
1481
  // Terminals: ports (after every other term at their nodes, so a held level can read its
2323
1482
  // flux), then contacts, then the circuit rows of the floating ones.
2324
1483
  this._terminals(dt);
2325
- if (Number.isFinite(dt) && (this.constraints.length > 0 || this.timeLaws.length > 0)) this._circuitLaws(dt);
2326
- // The steady system's own rows (see _laws): levels held flat, conserved amounts, islands'
2327
- // balances, the last two kept aside for _solveBordered.
2328
- const steady = this.steady && dt === Infinity;
2329
- if (steady) this._flatRows();
2330
- if (steady && this.affinities.length > 0) this._applyAffinities();
2331
- if (steady && this.constraints.length > 0) this._applyConstraints();
2332
- if (steady && this.localConstraints.length > 0) this._applyLocalConstraints();
2333
- this.pinnedIslands = steady && this.islandsOn ? this._applyIslands() : [];
2334
- 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);
2335
1487
  this.transformed = this.combining && dt !== Infinity;
2336
1488
  if (this.transformed) this._chargeRows(dt);
2337
1489
  }
@@ -3390,7 +2542,7 @@ export class Solver {
3390
2542
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
3391
2543
  const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
3392
2544
  // Its current, the charging (Q − Q at the step's start)/dt; or, steady at no current, the
3393
- // charge it keeps in place of that (see _captureLaws).
2545
+ // charge it keeps in place of that (see captureLaws in src/lawRows.js).
3394
2546
  const rate = Number.isFinite(dt) ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
3395
2547
  let Q = 0, dQdV = 0;
3396
2548
  port.nodes.forEach((g, w) => {
@@ -3522,7 +2674,8 @@ export class Solver {
3522
2674
  // φ law (none where φ is undefined: nothing at the end node responds to it). The displacement
3523
2675
  // into the device, D_in = sgn·D, adds (D_in − D_in at the step's start)/dt to the current.
3524
2676
  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.)
2677
+ // (In a steady solve, what a law in this circuit row counts of D_in: see applyConstraints in
2678
+ // src/lawRows.js.)
3526
2679
  const q = !dyn && this.steady && this.contactLaw[k] ? (this.contactQ[k] ??= { row: new Float64Array(this.sys.size + 1), D: 0, dV: 0 }) : null;
3527
2680
  if (q) {
3528
2681
  q.row.fill(0);
@@ -3540,7 +2693,7 @@ export class Solver {
3540
2693
  this.contactD[side] = sgn * Din;
3541
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];
3542
2695
  // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
3543
- // 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).
3544
2697
  const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
3545
2698
  if (rate > 0) {
3546
2699
  I += (Din - (dyn ? Dstart : this.chargeRefs.get(k))) * rate;
@@ -3591,92 +2744,6 @@ export class Solver {
3591
2744
 
3592
2745
 
3593
2746
 
3594
- // Steady solves: each flat stretch's level held at its contact's throughout (see
3595
- // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
3596
- // A flat stretch's level: its contact's, through the terminal k that holds it, or (reaching
3597
- // none) its first node's, which its amount fixes (that row is the amount's, a pin whose
3598
- // response moves the whole stretch: see _applyConstraints).
3599
- _flatLevel(st) {
3600
- const { M, u, uLo, z, VT, model } = this, i = st.species;
3601
- if (st.leftOpen || st.rightOpen) {
3602
- const side = st.leftOpen ? 'left' : 'right', link = model.contacts[side].species[i], k = side === 'left' ? 0 : 1;
3603
- return { k, hi: z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT, lo: 0 };
3604
- }
3605
- const o = this.blockOfNode[st.nodes[0]] * M + 1 + i;
3606
- return { k: -1, hi: u[o], lo: uLo[o] };
3607
- }
3608
-
3609
- // Each flat stretch's nodes set to its first node's level exactly (where Newton's damped
3610
- // updates have taken it, on the way to its contact's), so that it carries exactly no flux (the
3611
- // expm1 form of a zero difference). Flat only to round-off, a level moved by its contact had
3612
- // read as a current: a MOS capacitor's holes, their contact jumped to −6 V, as
3613
- // 3e-4 A/m² through a bulk that conducts ~1e10 S/m² (the true current was 3.5e-7).
3614
- _snapFlat() {
3615
- const { M, u, uLo } = this;
3616
- for (const st of this.flatStretches) {
3617
- const f = this.blockOfNode[st.nodes[0]] * M + 1 + st.species, hi = u[f], lo = uLo[f];
3618
- for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
3619
- const o = this.blockOfNode[g] * M + 1 + st.species;
3620
- if (this.loc[o] < 0) continue;
3621
- u[o] = hi;
3622
- uLo[o] = lo;
3623
- }
3624
- }
3625
- }
3626
-
3627
- _flatRows() {
3628
- const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
3629
- for (const st of this.flatStretches) {
3630
- const i = st.species, open = st.leftOpen || st.rightOpen;
3631
- const { k, hi, lo } = this._flatLevel(st);
3632
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
3633
- const b = this.blockOfNode[g], o = b * M + 1 + i;
3634
- if (this.loc[o] < 0) continue;
3635
- this._replaceRow(b, 1 + i);
3636
- for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
3637
- this._j(b, 1 + i, b, 1 + i, 1);
3638
- res[R[o]] = u[o] - hi + (uLo[o] - lo); // (the big terms first: hi + lo rounds to hi)
3639
- if (open && z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
3640
- }
3641
- // A face inside the stretch whose link holds the level continuous carries the species' flux
3642
- // as an unknown that only the edge balances, now replaced, determined: it's zero.
3643
- for (let f = st.regions[0]; f < st.regions[1]; f++) {
3644
- if (model.interfaces[f].links[i].type !== 'equilibrium') continue;
3645
- const b = this.blockOfFace[f], o = b * M + 1 + i;
3646
- if (this.loc[o] < 0) continue;
3647
- this._replaceRow(b, 1 + i);
3648
- for (const B of this.termB) B[R[o]] = 0;
3649
- this._j(b, 1 + i, b, 1 + i, 1);
3650
- res[R[o]] = u[o] + uLo[o];
3651
- }
3652
- }
3653
- }
3654
-
3655
- // Steady solves: each reaction that alone changes a combination (see _laws) held at its
3656
- // equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
3657
- // like an amount's).
3658
- _applyAffinities() {
3659
- const { M, u, uLo, res } = this, grid = this.model.grid;
3660
- for (const af of this.affinities) {
3661
- const b0 = Math.floor(af.row / M), r0 = af.row % M;
3662
- this._replaceRow(b0, r0);
3663
- this._j(b0, r0, b0, r0, 1);
3664
- res[this.rix[af.row]] = 0;
3665
- const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
3666
- let hi = af.rx.fixedA, lo = 0, len = 0;
3667
- for (const p of af.rx.part) {
3668
- const o = this.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
3669
- hi -= p.nu * u[o];
3670
- lo -= p.nu * uLo[o];
3671
- if (this.loc[o] < 0) continue;
3672
- af.idx[len] = this.rix[o];
3673
- af.w[len++] = -p.nu;
3674
- }
3675
- af.len = len;
3676
- af.res = hi + lo;
3677
- }
3678
- }
3679
-
3680
2747
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
3681
2748
  _replaceRow(b, rs) {
3682
2749
  const l = this.loc[b * this.M + rs];
@@ -3741,7 +2808,7 @@ export class Solver {
3741
2808
  // entries; where they're huge (an inversion layer's 0.1 nm cells, a conductance ~1e11 against
3742
2809
  // the layer's own storage and the trickle from the bulk), eliminating them loses the layer's
3743
2810
  // overall level to round-off, and Newton rattles there. delta and deltaV come in as the plain
3744
- // 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
3745
2812
  // if refining didn't help, leaving them as they came.
3746
2813
  _refine(dt, delta, deltaV) {
3747
2814
  const { res, termRes, termB, termC, termDI, rowDiv, floating: fl } = this;
@@ -3768,7 +2835,7 @@ export class Solver {
3768
2835
  rest.multiply(v, jv);
3769
2836
  if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
3770
2837
  lin.apply(v, jv, 1, 1 / dt);
3771
- // 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).
3772
2839
  for (const p of pins) {
3773
2840
  if (!p.col) continue;
3774
2841
  for (const o of p.col) if (o !== p.row && this.loc[o] >= 0) jv[R[o]] -= x[R[p.row]];
@@ -3801,7 +2868,7 @@ export class Solver {
3801
2868
  p.res = rhs[R[p.row]];
3802
2869
  rhs[R[p.row]] = 0;
3803
2870
  }
3804
- this._solveBordered(rhs, out, outV, pins);
2871
+ solveBordered(this, rhs, out, outV, pins);
3805
2872
  const z = new Float64Array(N + K);
3806
2873
  for (let j = 0; j < N; j++) z[j] = out[j];
3807
2874
  for (let a = 0; a < K; a++) z[N + a] = outV[a];
@@ -3863,7 +2930,8 @@ export class Solver {
3863
2930
  const history = [], ownHistory = [];
3864
2931
  // Refined solves (see _refine), once the plain ones stall.
3865
2932
  let refine = false, refined = 0, noRefine = false;
3866
- // 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.
3867
2935
  // Far from the solution, Newton exact along a weakly held level takes it by 1e5 thermal
3868
2936
  // units (a floating base, cold at bias), where the plain solves, which barely see that level,
3869
2937
  // converge. Converged, an island at the wrong level shows: it takes in a different current
@@ -3943,7 +3011,7 @@ export class Solver {
3943
3011
  try {
3944
3012
  const pins = this.pins;
3945
3013
  deltaV.fill(0);
3946
- this._solveBordered(res, delta, deltaV, pins);
3014
+ solveBordered(this, res, delta, deltaV, pins);
3947
3015
  if (refine && !this._refine(dt, delta, deltaV)) {
3948
3016
  refine = false; // (GMRES didn't converge, or strayed: the plain solves it is)
3949
3017
  noRefine = true;
@@ -4135,7 +3203,7 @@ export class Solver {
4135
3203
  this.sourceTime = this.steady ? this.steadyAt : tEnd;
4136
3204
  this.sourceBefore = !this.steady;
4137
3205
  this.combining = true;
4138
- this.lawForm = new Map(); // (see _circuitLaws)
3206
+ this.lawForm = new Map(); // (see circuitLaws in src/lawRows.js)
4139
3207
  let result;
4140
3208
  try {
4141
3209
  result = this.newton(dtEff, opts);
@@ -4152,7 +3220,8 @@ export class Solver {
4152
3220
  }
4153
3221
  if (!result.converged && Number.isFinite(dtEff) && [...this.lawForm.values()].some((v) => !v)) {
4154
3222
  // A terminal read by its flux whose response is lost to round-off (a floating gate's
4155
- // 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).
4156
3225
  this.u.set(this.uPrev);
4157
3226
  this.uLo.set(this.uPrevLo);
4158
3227
  this.termV.set(this.termVPrev);
@@ -5180,7 +4249,8 @@ export class Solver {
5180
4249
  const last = this.model.regions.length - 1;
5181
4250
  const { n, cOld } = this, vol = this.model.grid.vol;
5182
4251
  // A stretch alone in what a terminal driven by a current feeds takes in what that current
5183
- // 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.
5184
4254
  const driven = new Map();
5185
4255
  for (const cs of this.constraints) {
5186
4256
  if (cs.circuit < 0 || this.terms[cs.circuit].drive.kind !== 'I' || cs.parts.length !== 1 || cs.surface.length || cs.carriers.length) continue;
@@ -5225,7 +4295,7 @@ export class Solver {
5225
4295
  * Steady state: where a transient from the present state ends, found as the solution of the
5226
4296
  * steady equations (dt = ∞, no storage term), with no slow modes to wait out, however slow the
5227
4297
  * physics (e.g. exponentially scarce minority carriers). What the storage term held is held by
5228
- * 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
5229
4299
  * row that says nothing new, levels that carry nothing held flat, islands' summed balances.
5230
4300
  * Where Newton from here fails: continuation along the drives, then the pseudo-transient ramp.
5231
4301
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
@@ -5235,7 +4305,7 @@ export class Solver {
5235
4305
  solveSteady(opts = {}) {
5236
4306
  this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
5237
4307
  this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
5238
- this._captureLaws();
4308
+ captureLaws(this);
5239
4309
  this.steady = true; // (the steady system's rows; sources at the present time)
5240
4310
  this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
5241
4311
  try {
@@ -5254,7 +4324,7 @@ export class Solver {
5254
4324
  const I = sourceAt(this.terms[k].drive.src, this.time);
5255
4325
  if (this.terms[k].drive.kind !== 'I' || I === 0) continue;
5256
4326
  const laws = this.constraints.filter((cs) => cs.terminals.includes(k));
5257
- if (laws.length === 0 && !this._chargeOnly(k)) continue;
4327
+ if (laws.length === 0 && !chargeOnly(this, k)) continue;
5258
4328
  const what = laws.flatMap((cs) => cs.parts.map(({ stretch }) => {
5259
4329
  const st = this.stretches[stretch], [r0, r1] = st.regions;
5260
4330
  return `${species[st.species].name} in ${regions[r0].name}${r1 > r0 ? `…${regions[r1].name}` : ''}`;
@@ -5274,7 +4344,7 @@ export class Solver {
5274
4344
  _steadyFromHere(opts) {
5275
4345
  this.conditioning = null;
5276
4346
  this.computeConcentrations();
5277
- 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
5278
4348
  this.sourceTime = this.time;
5279
4349
  // Continuation applies where the right terminal is held at a voltage, against a held left one.
5280
4350
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
@@ -5370,20 +4440,7 @@ export class Solver {
5370
4440
  // only charges a capacitance (so that its steady states are one per amount or charge, not one
5371
4441
  // per voltage).
5372
4442
  _bound(k) {
5373
- return this.constraints.some((cs) => cs.terminals.includes(k)) || this._chargeOnly(k);
5374
- }
5375
-
5376
- // Whether terminal k passes current only by charging a capacitance: no charged species through
5377
- // it (nor, a port, reactions), and a capacitance to charge (a gate's φ law, a port's own).
5378
- _chargeOnly(k) {
5379
- const t = this.terms[k], { species } = this.model;
5380
- const ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
5381
- if (t.kind === 'port') {
5382
- const p = this.model.ports[t.index];
5383
- return p.reactions.length === 0 && !ions(p.species) && !!p.capacitance;
5384
- }
5385
- const c = this.model.contacts[t.side];
5386
- 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);
5387
4444
  }
5388
4445
 
5389
4446
  // A cold steady solve with a terminal driven by a current, which can fail where the same state