driftlet 0.14.0 → 0.16.4

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
@@ -478,6 +478,7 @@ export class Solver {
478
478
  model.ports.forEach((port, k) => port.capacitance && port.nodes.forEach((g, w) => this.qgTerms[g].push([2 + k, w])));
479
479
  this.contactDOld = { left: 0, right: 0 };
480
480
  this.contactDStart = { left: 0, right: 0 };
481
+ this.lawForm = new Map(); // (each circuit law: read as its change this step, see _circuitLaws)
481
482
  // Terminals (the two contacts, then the ports): each one's voltage (V), held by its source or
482
483
  // floating (driven by a current, or behind a resistance), and its current into the device.
483
484
  // Sources are read at sourceTime: a step's end, or now for a steady solve.
@@ -490,6 +491,9 @@ export class Solver {
490
491
  this.steady = false; // inside a steady solve: sources at the present time, not a step's end
491
492
  // The floating ones are extra unknowns, solved with the grid's by bordering (_solveBordered).
492
493
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
494
+ this.drivenKey = this._drivenKey();
495
+ this.lawRefs = new Map(); // what a steady solve keeps behind a terminal at no current (_captureLaws)
496
+ this.chargeRefs = new Map(); // …and the charge a terminal at no current that only charges keeps
493
497
  // Per terminal: B = ∂res/∂V (compact column), C = ∂I/∂x (compact row), ∂I/∂V, and for a
494
498
  // floating one its circuit residual (I − I_set, or I − (V_src − V)/R).
495
499
  this.termB = this.terms.map(() => new Float64Array(this.sys.size + 1));
@@ -725,49 +729,125 @@ export class Solver {
725
729
  st.mobile = true;
726
730
  for (let q = st.regions[0]; q <= st.regions[1]; q++) if (!(materials[regions[q].material].D[st.species] > 0)) st.mobile = false;
727
731
  }
728
- // Stretches whose steady state is known outright: a species that no reaction or port touches,
729
- // reached by one contact only (the other end blocked), carries no flux at steady state, so its
730
- // level is flat at that contact's. Steady solves pin it there (_flatRows) rather than find it
731
- // through its own conduction, which can be all but nothing: a MOS capacitor's inversion
732
- // electrons reach the back contact only through a bulk with ~1e3 of them per cm³. (Not
733
- // through flow or mixing, nor a concentrated material's cross-diffusion, where zero flux
734
- // isn't a flat level.)
735
- this.flatStretches = this.stretches.filter((st) => {
736
- if (st.reactive || st.ports.length > 0 || !st.mobile || st.leftOpen === st.rightOpen) return false;
732
+ // Stretches that carry nothing in a steady state, so whose level is flat there: a species that
733
+ // no reaction or port touches, reached by one contact at most, moving through ideal regions
734
+ // without flow or mixing (where carrying nothing is a level flat). Found through their own
735
+ // conduction, which can be all but nothing, such a level can't be moved (a MOS capacitor's
736
+ // inversion electrons, which reach the back contact only through a bulk with ~1e3 of them per
737
+ // cm³; blocked SO₄²⁻ driven from a zinc cathode's extended space charge, down to 1e-25 mol/m³
738
+ // there, whose chain of conductances lost the system 14 digits). So steady solves hold it
739
+ // flat (_flatRows): at its contact's level, or reaching none, at the level its amount fixes.
740
+ // (Not through flow or mixing, nor a concentrated material's cross-diffusion, where carrying
741
+ // nothing isn't a level flat.)
742
+ const plain = (st) => {
743
+ if (st.ports.length > 0 || !st.mobile || (st.leftOpen && st.rightOpen)) return false;
737
744
  for (let q = st.regions[0]; q <= st.regions[1]; q++) {
738
745
  const reg = regions[q], mat = materials[reg.material];
739
746
  if (mat.conductor || !mat.ideal || reg.velocity !== 0 || reg.mixing > 0) return false;
740
747
  }
741
748
  return true;
749
+ };
750
+ this.flatStretches = this.stretches.filter((st) => !st.reactive && plain(st));
751
+ // Likewise a group of stretches that react only among themselves, each reaching the same one
752
+ // contact and nothing else, their reactions balanced at that contact's levels (no light, no
753
+ // other reservoir): in a steady state, one reservoir drives nothing, so the group is at
754
+ // equilibrium, each level flat at the contact's. A MOS capacitor's electrons and the holes
755
+ // they recombine with reach only its back contact: moved, slow generation through a bulk of
756
+ // GaAs with ~1e-4 electrons per cm³ couldn't carry the inversion layer's level with it.
757
+ const group = this.stretches.map((_, k) => k), root = (k) => (group[k] === k ? k : (group[k] = root(group[k])));
758
+ const join = (ks) => ks.forEach((k) => (group[root(k)] = root(ks[0])));
759
+ const rxns = []; // [stretches, A/RT at the contact's levels, as a function of the side]
760
+ regions.forEach((reg, q) => {
761
+ for (const rx of this.rxsIn[reg.material]) {
762
+ const ks = rx.sp.map((i) => this.stretchOf[q * n + i]);
763
+ join(ks);
764
+ rxns.push([ks, (side) => rx.sp.reduce((a, i, p) => a + rx.nu[p] * this.contactEta(side, i), rx.fixedA)]);
765
+ }
766
+ });
767
+ model.interfaces.forEach((itf, f) => {
768
+ for (const rx of itf.reactions) {
769
+ const ks = rx.part.map((p) => this.stretchOf[(f + p.side) * n + p.i]);
770
+ if (ks.some((k) => k < 0)) continue;
771
+ join(ks);
772
+ rxns.push([ks, (side) => rx.part.reduce((a, p) => a + p.nu * this.contactEta(side, p.i), rx.fixedA)]);
773
+ }
742
774
  });
743
- // Likewise a spectator that's mobile throughout: no flux at steady state, so its level is flat,
744
- // at a value its amount fixes. Steady solves hold its levels equal (_levelRows) rather than
745
- // find them through its own conduction: blocked SO₄²⁻ driven from a zinc cathode's extended
746
- // space charge is down to 1e-25 mol/m³ there, and the chain of conductances through it lost
747
- // the steady system 14 digits. (Within one region: across a face, the rows that would hold
748
- // the level and pin the face's flux leave a diagonal block singular.)
749
- this.levelStretches = this.stretches.filter((st) => {
750
- if (!st.spectator || !st.mobile || st.ports.length > 0 || st.regions[0] !== st.regions[1]) return false;
751
- const reg = regions[st.regions[0]], mat = materials[reg.material];
752
- return !mat.conductor && mat.ideal && reg.velocity === 0 && !(reg.mixing > 0);
775
+ const groups = new Map();
776
+ this.stretches.forEach((st, k) => {
777
+ if (!st.reactive) return;
778
+ if (!groups.has(root(k))) groups.set(root(k), []);
779
+ groups.get(root(k)).push(k);
753
780
  });
754
- this.flattening = false;
781
+ for (const ks of groups.values()) {
782
+ const sts = ks.map((k) => this.stretches[k]), side = sts[0].leftOpen ? 'left' : 'right';
783
+ if (!sts.every((st) => plain(st) && st.leftOpen !== st.rightOpen && (side === 'left' ? st.leftOpen : st.rightOpen))) continue;
784
+ if (!rxns.every(([rk, A]) => !rk.some((k) => ks.includes(k)) || Math.abs(A(side)) < 1e-9)) continue;
785
+ this.flatStretches.push(...sts);
786
+ }
787
+ this._laws();
788
+ }
789
+
790
+ // Whether a stretch's species moves through it (mobile in every region, or a metal's carrier),
791
+ // or stays still in every region (D = 0: trap states, conserved node by node).
792
+ _moves(st) {
793
+ const { regions, materials } = this.model;
794
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
795
+ const mat = materials[regions[q].material];
796
+ if (!(mat.conductor || mat.D[st.species] > 0)) return false;
797
+ }
798
+ return true;
799
+ }
800
+
801
+ _still(st) {
802
+ const { regions, materials } = this.model;
803
+ for (let q = st.regions[0]; q <= st.regions[1]; q++) {
804
+ const mat = materials[regions[q].material];
805
+ if (mat.conductor || mat.D[st.species] > 0) return false;
806
+ }
807
+ return true;
808
+ }
809
+
810
+ // Whether terminal k (a contact, or port k − 2) feeds stretch st: links its species there.
811
+ _feeds(k, st) {
812
+ return k === 0 ? st.leftOpen : k === 1 ? st.rightOpen : st.linked.includes(k - 2);
813
+ }
814
+
815
+ // What a steady solve keeps (under the terminals' present drives, so again after a change of
816
+ // drive): each conserved amount, solved directly in place of one (redundant) balance row, and
817
+ // the islands' summed balances.
818
+ _laws() {
819
+ const { model } = this, { regions, materials, grid } = model;
820
+ const laws = this._conservedMoieties(true);
821
+ for (const st of this.stretches) st.conserved = false;
755
822
  // Conserved amounts solved directly, each in place of one (redundant) balance row: a
756
- // spectator's own amount, and each conserved combination of reacting stretches (the total
757
- // iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose weighted balances sum to zero at steady state.
758
- // Immobile stretches conserve node by node instead, and a floating conductor holds charge on
759
- // its faces; both are left to giant time steps.
823
+ // spectator's own amount (a floating metal's: the charge on its faces), each conserved
824
+ // combination of reacting stretches (the total iron of Fe³⁺, Fe²⁺ and FeCl²⁺, say), whose
825
+ // weighted balances sum to zero at steady state, and what a terminal driven at no current
826
+ // keeps behind it. A combination may take in still stretches (the electrons trap states hold),
827
+ // their amounts summed like any other; one of still stretches alone conserves node by node
828
+ // instead (below), and one of a stretch still in part only is left to giant time steps.
760
829
  this.constraints = [];
761
830
  const conductor = (st) => materials[regions[st.regions[0]].material].conductor;
762
- const add = (parts, rowStretch, surface = []) => {
831
+ // The terminals driven by a current that feed a stretch: a combination through one keeps what
832
+ // the state holds when a solve begins (see _captureLaws), and has no steady state at any other
833
+ // current.
834
+ const driven = (st) => this.terms.flatMap((t, k) => (t.drive.kind === 'I' && this._feeds(k, st) ? [k] : []));
835
+ const add = (parts, rowStretch, surface = [], circuit = -1, carriers = []) => {
763
836
  const st = this.stretches[rowStretch];
764
837
  let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
765
838
  for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
839
+ for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
766
840
  for (const p of parts) this.stretches[p.stretch].conserved = true;
767
841
  this.constraints.push({
768
842
  parts,
769
843
  surface,
770
- row: this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
844
+ carriers, // (what a port's electrode holds, Q/(zF) at weight w: see _conservedMoieties)
845
+ terminals: [...new Set([...parts.flatMap((p) => driven(this.stretches[p.stretch])), ...carriers.map((p) => p.k)])],
846
+ 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(),
847
+ circuit, // (the terminal whose circuit row it stands in for, or −1 for a balance row's, `row`)
848
+ // (and the terminal's weight in it, w/z on what that feeds: the amount changes by λ I/F)
849
+ 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)),
850
+ row: circuit >= 0 ? -1 : this.blockOfNode[st.nodes[0]] * this.M + 1 + st.species,
771
851
  idx: new Int32Array(nNodes * this.M),
772
852
  w: new Float64Array(nNodes * this.M),
773
853
  len: 0,
@@ -776,38 +856,151 @@ export class Solver {
776
856
  });
777
857
  };
778
858
  this.stretches.forEach((st, k) => {
779
- if (st.spectator && st.mobile) add([{ stretch: k, w: 1 }], k);
859
+ if (!st.spectator || !this._moves(st)) return;
860
+ add([{ stretch: k, w: 1 }], k);
861
+ // Held flat (see _flatRows), it moves as a whole: the pin's response is the whole stretch's
862
+ // shift, its amount saying how far.
863
+ 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);
780
864
  });
781
- const S = this.stretches.length;
782
- for (const w of this.moieties) {
865
+ const S = this.stretches.length, T = this.terms.length, X = S + this.surfCols.length;
866
+ const zc = (k) => this.z[model.ports[k - 2].terminal];
867
+ // The rows replaced: a moving stretch's balance (one of still stretches alone is kept node by
868
+ // node, below), or the circuit row of a terminal driven at no current, which says nothing the
869
+ // combination's balance rows don't (their weighted sum is that terminal's current, weighted
870
+ // by w/z). Together they must be independent, each combination's weights over the rows
871
+ // replaced a nonsingular matrix, or one balance is lost and another said twice (A + B = AB and
872
+ // 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
873
+ // A twice and C not at all, and the solve converged to a state that wasn't steady). So they're
874
+ // chosen by elimination, each combination's weights reduced by the rows already chosen: a
875
+ // circuit row where one is left, else the first stretch with weight 1, else the basis
876
+ // vector's own (its weight is 1 there, and no other vector's is), else the largest.
877
+ const rowOf = (k) => this.blockOfNode[this.stretches[k].nodes[0]] * this.M + 1 + this.stretches[k].species;
878
+ const chosen = []; // (a column and the reduced weights, per row replaced: S + t for terminal t's)
879
+ const spectators = new Set(this.constraints.map((cs) => cs.row));
880
+ // What an electrode holds goes only in its terminal's circuit row (its charge depends on the
881
+ // terminal's voltage, which a balance row's pin can't carry): the combinations through it come
882
+ // first, so that the first takes that row, and the rest have its multiple taken off.
883
+ const through = (w) => this.carriers.some((k, j) => w[X + j] !== 0);
884
+ const held = new Map(); // (terminal → the combination in place of its circuit row)
885
+ // Reactions' equilibria first. A face reaction that alone changes some combination (without
886
+ // it, w would be conserved: Ag⁺ closed in a cell where Ag⁺ + e⁻ = Ag(s) is its one source, or
887
+ // Ag⁺ + AgNO₃ where it also complexes) has that combination's summed balance rows equal to its
888
+ // rate, w·ν times it, which a steady state sets to zero. The rate is a poor row: k₀ times a
889
+ // function of the levels, beside diffusion's terms in the same balances. An Ag⁺/Ag couple
890
+ // 1e-20 as fast as Fe³⁺/Fe²⁺ on the same platinum was lost to their round-off, the
891
+ // open-circuit potential settling where silver wasn't (−0.4661 V, the joint equilibrium
892
+ // −0.5004 V), and 1e-15 to 1e-18 failed. Its affinity says the same at any k₀ (each kinetic
893
+ // form vanishes only there), so it stands in for one of those rows (see _applyAffinities),
894
+ // chosen by the same elimination, and a stretch's: never a circuit row.
895
+ // The slowest first, whose rate rows are the poorest, and each row picked by the reaction's
896
+ // own terms, never by where its species are listed: the same rows in any listing. (Picked by
897
+ // listing, an electrode's two couples took different rows with the species reversed, and
898
+ // Newton converged in one listing and crawled in the other.)
899
+ this.affinities = [];
900
+ const faceRx = model.interfaces.flatMap((itf, f) => itf.reactions.map((rx) => ({ f, rx, key: rx.part.map((p) => `${model.species[p.i].name}:${p.side}:${p.nu}`).sort().join() })));
901
+ faceRx.sort((a, b) => a.rx.k0 - b.rx.k0 || (a.key < b.key ? -1 : a.key > b.key ? 1 : a.f - b.f));
902
+ for (const { f, rx } of faceRx) {
903
+ const nu = new Float64Array(S);
904
+ let inFlat = false;
905
+ for (const p of rx.part) {
906
+ const k = this.stretchOf[(f + p.side) * this.n + p.i];
907
+ if (k < 0 || this.flatStretches.includes(this.stretches[k])) inFlat = true;
908
+ else nu[k] += p.nu;
909
+ }
910
+ if (inFlat) continue; // (held flat at a contact's levels, in equilibrium already)
911
+ // (Not one fed by a terminal driven by a current: its sum is the rate plus what that
912
+ // passes. Silver's, e⁻ + Fe²⁺ with the electrons fed, or Ag⁺ + AgNO₃, which nothing is.
913
+ // A current held at zero passes nothing: an electrode at open circuit feeding a closed
914
+ // pool of Ag⁺ beside a slow Fe³⁺/Fe²⁺ couple settled 78 mV off, its iron not stationary.)
915
+ const passes = (k) => !this._noCurrent(this.terms[k]);
916
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && driven(this.stretches[k]).some(passes))));
917
+ // Any of the combinations it alone changes will do, with any conserved amount added (whose
918
+ // summed balance is zero too): one of them needs a row of its own left to take. (Silver's,
919
+ // e⁻ − Fe³⁺ beside a closed iron couple that had Fe³⁺'s, is e⁻ + Fe²⁺ with the iron added.)
920
+ const news = this._conservedMoieties(true, [], rx).filter((w) => Math.abs(nu.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9 && !fed(w));
921
+ const kept = laws.filter((l) => !fed(l));
922
+ const tries = [...news, ...news.flatMap((w) => kept.flatMap((l) => [1, -1].map((sg) => w.map((x, m) => x + sg * l[m]))))];
923
+ for (const w of tries) {
924
+ const v = new Float64Array(S + T);
925
+ for (let k = 0; k < S; k++) {
926
+ if (w[k] === 0) continue;
927
+ const st = this.stretches[k];
928
+ if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = w[k];
929
+ if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = w[k] / this.z[st.species];
930
+ }
931
+ for (const [col, row] of chosen) {
932
+ const fc = v[col];
933
+ if (fc !== 0) for (let j = 0; j < S + T; j++) v[j] -= fc * row[j];
934
+ }
935
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
936
+ if (!(scale > 1e-9)) continue;
937
+ // (Never a metal's carriers, nor a stretch a terminal feeds, even at no current: their rows
938
+ // carry the terminal's current, and Pt's electrons standing for an iron couple's balance
939
+ // put the electrode at 602 V.)
940
+ const ok = (j) => j !== undefined && j >= 0 && j < S && Math.abs(v[j]) > 1e-9 * scale && !conductor(this.stretches[j]) && this.terms.every((_, t) => !this._feeds(t, this.stretches[j]));
941
+ // (One of its own species first, then weight 1, the largest, and by name.)
942
+ const own = (k) => rx.part.some((p) => p.i === this.stretches[k].species && this.stretchOf[(f + p.side) * this.n + p.i] === k);
943
+ const key = (k) => [own(k) ? 0 : 1, Math.abs(v[k]) === 1 ? 0 : 1, -Math.abs(v[k]), model.species[this.stretches[k].species].name, this.stretches[k].regions[0]];
944
+ const less = (a, b) => { const ka = key(a), kb = key(b); for (let j = 0; j < ka.length; j++) if (ka[j] !== kb[j]) return ka[j] < kb[j]; return false; };
945
+ const col = [...Array(S).keys()].filter(ok).reduce((b, k) => (b < 0 || less(k, b) ? k : b), -1);
946
+ if (col < 0) continue;
947
+ chosen.push([col, v.map((x) => x / v[col])]);
948
+ this.affinities.push({ rx, f, row: rowOf(col), idx: new Int32Array(rx.part.length), w: new Float64Array(rx.part.length), len: 0, res: 0 });
949
+ break;
950
+ }
951
+ }
952
+ for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
953
+ for (const [j, k] of this.carriers.entries()) {
954
+ const c = held.get(k);
955
+ if (w[X + j] === 0 || !c) continue;
956
+ const f = w[X + j] / c[X + j], own = w.own;
957
+ w = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
958
+ w.own = own;
959
+ }
783
960
  const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
784
- const surface = [...w.keys()].filter((k) => k >= S && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
785
- if (parts.length === 0) continue; // (a surface's own: left to giant time steps)
961
+ const surface = [...w.keys()].filter((k) => k >= S && k < X && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
962
+ const carriers = this.carriers.flatMap((k, j) => (w[X + j] !== 0 ? [{ k, w: w[X + j] }] : []));
963
+ if (parts.length === 0) continue; // (a surface's own: node by node, below)
786
964
  if (parts.length === 1 && surface.length === 0 && this.stretches[parts[0].stretch].spectator) continue; // (above)
787
- if (parts.some((p) => !this.stretches[p.stretch].mobile || conductor(this.stretches[p.stretch]))) continue;
788
- // The row replaced: the basis vector's own stretch (its weight is 1, and no other vector has one).
789
- add(parts, parts.find((p) => w[p.stretch] === 1)?.stretch ?? parts[0].stretch, surface);
790
- }
791
- // A combination of immobile stretches (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻) is conserved
792
- // node by node, since nothing carries it anywhere: at each node its weighted sum stays what
793
- // it was, a row local to that node's block, in place of the balance row of one of them.
965
+ if (parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
966
+ const v = new Float64Array(S + T);
967
+ for (const { stretch: k, w: wk } of parts) {
968
+ const st = this.stretches[k];
969
+ if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = wk;
970
+ if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = wk / this.z[st.species];
971
+ }
972
+ for (const { k, w: wk } of carriers) v[S + k] = wk / zc(k);
973
+ for (const [col, row] of chosen) {
974
+ const f = v[col];
975
+ if (f !== 0) for (let j = 0; j < S + T; j++) v[j] -= f * row[j];
976
+ }
977
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
978
+ if (!(scale > 1e-9)) continue;
979
+ const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
980
+ 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);
981
+ 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)
982
+ chosen.push([col, v.map((x) => x / v[col])]);
983
+ if (col >= S) held.set(col - S, w);
984
+ add(parts, col < S ? col : parts[0].stretch, surface, col < S ? -1 : col - S, carriers);
985
+ }
986
+ // A combination that nothing carries anywhere is conserved node by node: of still stretches
987
+ // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
988
+ // that only turn into each other). At each node its weighted sum stays what it was, a row local
989
+ // to that node's block, in place of one of their balances.
794
990
  this.localConstraints = [];
795
- const immobile = (st) => {
796
- for (let q = st.regions[0]; q <= st.regions[1]; q++) if (materials[regions[q].material].D[st.species] > 0) return false;
797
- return true;
798
- };
799
- for (const w of this.moieties) {
800
- const parts = [...w.keys()].filter((k) => w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
801
- const sts = parts.map((p) => this.stretches[p.stretch]);
802
- if (sts.some((st) => st.conserved || !immobile(st) || conductor(st))) continue;
803
- if (sts.some((st) => st.nodes[0] !== sts[0].nodes[0] || st.nodes[1] !== sts[0].nodes[1])) continue; // (side by side only)
804
- for (const st of sts) st.conserved = true;
805
- const row = parts.find((p) => p.w === 1) ?? parts[0];
991
+ const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
992
+ for (const w of laws) {
993
+ const parts = [...w.keys()].filter((k) => w[k] !== 0);
994
+ if (parts.some((k) => k >= X)) continue; // (through an electrode: above)
995
+ 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));
996
+ if (sets.some((set) => set === null || set.length !== sets[0].length || set.some((g, j) => g !== sets[0][j]))) continue; // (side by side only)
997
+ for (const k of parts) if (k < S) this.stretches[k].conserved = true;
998
+ const slot = (k) => (k < S ? 1 + this.stretches[k].species : 1 + this.n + this.surfCols[k - S][1]);
806
999
  this.localConstraints.push({
807
- parts: parts.map((p) => ({ i: this.stretches[p.stretch].species, w: p.w })),
808
- row: 1 + this.stretches[row.stretch].species,
809
- nodes: sts[0].nodes,
810
- reference: new Float64Array(sts[0].nodes[1] - sts[0].nodes[0] + 1),
1000
+ parts: parts.map((k) => (k < S ? { i: this.stretches[k].species, w: w[k] } : { q: this.surfCols[k - S][1], w: w[k] })),
1001
+ row: slot(parts.includes(w.own) ? w.own : parts[0]),
1002
+ nodes: sets[0],
1003
+ reference: new Float64Array(sets[0].length),
811
1004
  });
812
1005
  }
813
1006
  // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
@@ -821,7 +1014,7 @@ export class Solver {
821
1014
  this.pinnedIslands = [];
822
1015
  this.islandsOn = false; // (newton() turns them on, see there)
823
1016
  this.pins = [];
824
- const used = new Set(this.constraints.map((cs) => cs.row));
1017
+ const used = new Set([...this.constraints.map((cs) => cs.row).filter((o) => o >= 0), ...this.affinities.map((af) => af.row)]);
825
1018
  for (const st of this.stretches) {
826
1019
  const i = st.species, [s0, s1] = st.regions;
827
1020
  if (!st.mobile || conductor(st)) continue;
@@ -870,19 +1063,68 @@ export class Solver {
870
1063
  }
871
1064
  }
872
1065
  for (const isl of this.islands) isl.outside = isl.outside.filter((o) => !used.has(o));
873
- this.constrained = false;
1066
+
1067
+ // Time steps only: a terminal behind a resistance that alone feeds a combination reads its
1068
+ // current by that combination's change too (see _circuitLaws), its combinations found with it
1069
+ // counted as driven by a current. (Not in the steady system, where it holds its level.)
1070
+ this.timeLaws = [];
1071
+ const behind = this.floating.filter((k) => this.terms[k].drive.kind !== 'I');
1072
+ if (behind.length > 0) {
1073
+ const steadyCarriers = this.carriers, more = this._conservedMoieties(true, behind), X2 = S + this.surfCols.length;
1074
+ for (const w of more) {
1075
+ const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
1076
+ if (parts.length === 0 || parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
1077
+ const surface = [...w.keys()].filter((k) => k >= S && k < X2 && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
1078
+ const carriers = this.carriers.flatMap((k, j) => (w[X2 + j] !== 0 ? [{ k, w: w[X2 + j] }] : []));
1079
+ // Fed by one terminal behind a resistance and nothing else that passes current.
1080
+ const fedBy = new Set([...carriers.map((p) => p.k), ...parts.flatMap((p) => this.floating.filter((k) => this._feeds(k, this.stretches[p.stretch])))]);
1081
+ if (fedBy.size !== 1) continue;
1082
+ const [k] = fedBy;
1083
+ if (!behind.includes(k) || this.timeLaws.some((cs) => cs.circuit === k)) continue;
1084
+ const zc = k >= 2 ? this.z[model.ports[k - 2].terminal] : 0;
1085
+ 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);
1086
+ if (!lambda) continue;
1087
+ let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
1088
+ for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
1089
+ for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
1090
+ 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 });
1091
+ }
1092
+ this.carriers = steadyCarriers;
1093
+ }
1094
+ }
1095
+
1096
+ // What each combination fed by a terminal driven at no current holds now, which a steady solve
1097
+ // keeps: a closed battery at open circuit stays at the charge it had.
1098
+ _captureLaws() {
1099
+ // A terminal at no current that passes current only by charging a capacitance keeps its charge.
1100
+ this.chargeRefs.clear();
1101
+ const charged = this.floating.filter((k) => this.terms[k].drive.kind === 'I' && this._chargeOnly(k));
1102
+ if (charged.length > 0) {
1103
+ this._assembleBookkeeping(Infinity);
1104
+ for (const k of charged) this.chargeRefs.set(k, k >= 2 ? this.portQ[k - 2] : k === 0 ? this.contactD.left : -this.contactD.right);
1105
+ }
1106
+ this.lawRefs.clear();
1107
+ if (this.constraints.some((cs) => cs.carriers.some(({ k }) => this.model.ports[k - 2].capacitance)) && charged.length === 0) this._assembleBookkeeping(Infinity);
1108
+ for (const cs of this.constraints) {
1109
+ if (cs.terminals.length === 0) continue;
1110
+ let a = 0;
1111
+ for (const { stretch, w } of cs.parts) a += w * this.amount(this.stretches[stretch]);
1112
+ for (const { col, w } of cs.surface) a += w * this.surfaceAmount(col);
1113
+ 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);
1114
+ this.lawRefs.set(cs.key, a);
1115
+ }
874
1116
  }
875
1117
 
876
1118
  // Each local constraint's weighted sum at each node, as the state holds it now: what a steady
877
1119
  // solve keeps.
878
1120
  _captureLocal() {
879
- const { n, c } = this;
1121
+ const { n, c, th, nSurf } = this;
880
1122
  for (const lc of this.localConstraints) {
881
- for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
1123
+ lc.nodes.forEach((g, j) => {
882
1124
  let t = 0;
883
- for (const { i, w } of lc.parts) t += w * c[g * n + i];
884
- lc.reference[g - lc.nodes[0]] = t;
885
- }
1125
+ for (const { i, q, w } of lc.parts) t += w * (i !== undefined ? c[g * n + i] : th[g * nSurf + q]);
1126
+ lc.reference[j] = t;
1127
+ });
886
1128
  }
887
1129
  }
888
1130
 
@@ -892,7 +1134,7 @@ export class Solver {
892
1134
  _applyIslands() {
893
1135
  const { u, res } = this, R = this.rix, out = [];
894
1136
  for (const isl of this.islands) {
895
- if (this.flattening && this.flatStretches.includes(isl.stretch)) continue;
1137
+ if (this.flatStretches.includes(isl.stretch)) continue;
896
1138
  const b0 = Math.floor(isl.row / this.M), r0 = isl.row % this.M;
897
1139
  if (this.loc[isl.row] < 0) continue;
898
1140
  // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
@@ -953,34 +1195,55 @@ export class Solver {
953
1195
 
954
1196
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
955
1197
  _applyLocalConstraints() {
956
- const { n, M, c, res, dA: d } = this;
1198
+ const { n, M, c, th, nSurf, res, dA: d } = this;
957
1199
  for (const lc of this.localConstraints) {
958
- for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
1200
+ lc.nodes.forEach((g, j) => {
959
1201
  const b = this.blockOfNode[g];
960
- if (this.loc[b * M + lc.row] < 0) continue;
1202
+ if (this.loc[b * M + lc.row] < 0) return;
961
1203
  this._replaceRow(b, lc.row);
962
1204
  let t = 0;
963
- for (const { i, w } of lc.parts) {
964
- t += w * c[g * n + i];
965
- this._dc(g, i, d);
966
- for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1205
+ for (const { i, q, w } of lc.parts) {
1206
+ if (i !== undefined) {
1207
+ t += w * c[g * n + i];
1208
+ this._dc(g, i, d);
1209
+ for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1210
+ continue;
1211
+ }
1212
+ // A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species.
1213
+ const tq = th[g * nSurf + q], ns = this.model.ports[this.surfPort[g]].surface.length;
1214
+ t += w * tq;
1215
+ 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]));
967
1216
  }
968
- res[this.rix[b * M + lc.row]] = t - lc.reference[g - lc.nodes[0]];
969
- }
1217
+ res[this.rix[b * M + lc.row]] = t - lc.reference[j];
1218
+ });
970
1219
  }
971
1220
  }
972
1221
 
973
1222
  // A basis of the conserved combinations of stretch amounts: the null space of the
974
- // stoichiometry (a row per reaction, over the stretches it touches) together with a unit row
975
- // for each stretch fed from outside. Exact integer data, so plain elimination will do.
976
- _conservedMoieties() {
977
- const { model, n } = this, S = this.stretches.length, C = S + this.surfCols.length;
978
- const rows = [];
1223
+ // stoichiometry (a row per reaction, over the stretches it touches) together with what the
1224
+ // terminals feed. A terminal held at a voltage (or behind a resistance, which holds it at
1225
+ // steady state) feeds each stretch it links freely: a unit row. With `drives`, one driven by a
1226
+ // current feeds its charged species only as that current allows, in proportion to their
1227
+ // charges, so a combination weighted by z there is conserved while it passes none: a battery's
1228
+ // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
1229
+ // elimination will do.
1230
+ _conservedMoieties(drives = false, alsoCurrent = [], skip = null) {
1231
+ const { model, n, z } = this, S = this.stretches.length;
1232
+ // With `drives`, an electrode port driven by a current whose reactions take its carrier from
1233
+ // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
1234
+ // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
1235
+ // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
1236
+ const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
1237
+ this.carriers = drives ? this.terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && model.ports[k - 2].reactions.length > 0 ? [k] : [])) : [];
1238
+ const X = S + this.surfCols.length, C = X + this.carriers.length;
1239
+ // (With `skip`, a reaction left out: the combinations that it alone changes come out too, and
1240
+ // its own rows, over the columns, are handed back as `basis.skipped`.)
1241
+ const rows = [], skipped = [];
979
1242
  model.interfaces.forEach((itf, f) => {
980
1243
  for (const rx of itf.reactions) {
981
1244
  const row = new Float64Array(C);
982
1245
  for (const p of rx.part) row[this.stretchOf[(f + p.side) * n + p.i]] += p.nu;
983
- rows.push(row);
1246
+ (rx === skip ? skipped : rows).push(row);
984
1247
  }
985
1248
  });
986
1249
  // An electrode surface's species are columns too, after the stretches (S + j for surface
@@ -989,11 +1252,13 @@ export class Solver {
989
1252
  model.ports.forEach((port, k) => {
990
1253
  for (const rx of port.reactions) {
991
1254
  const row = new Float64Array(C); // (the electrode's carrier is outside the device)
1255
+ if (rx === skip) skipped.push(row);
992
1256
  for (const p of rx.part) {
993
1257
  if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
994
1258
  else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
1259
+ else if (this.carriers.includes(k + 2)) row[X + this.carriers.indexOf(k + 2)] += p.nu;
995
1260
  }
996
- rows.push(row);
1261
+ if (rx !== skip) rows.push(row);
997
1262
  }
998
1263
  });
999
1264
  model.regions.forEach((reg, q) => {
@@ -1002,19 +1267,35 @@ export class Solver {
1002
1267
  const row = new Float64Array(C);
1003
1268
  for (const p of rx.reactants) row[this.stretchOf[q * n + p.i]] -= p.nu;
1004
1269
  for (const p of rx.products) row[this.stretchOf[q * n + p.i]] += p.nu;
1005
- rows.push(row);
1270
+ (rx === skip ? skipped : rows).push(row);
1006
1271
  }
1007
1272
  });
1008
- this.stretches.forEach((st, k) => {
1009
- if (!st.contactFed) return;
1273
+ const unit = (k) => {
1010
1274
  const row = new Float64Array(C);
1011
1275
  row[k] = 1;
1012
1276
  rows.push(row);
1277
+ };
1278
+ this.terms.forEach((t, k) => {
1279
+ const fed = this.stretches.flatMap((st, j) => (this._feeds(k, st) ? [j] : []));
1280
+ if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
1281
+ const charged = fed.filter((j) => z[this.stretches[j].species] !== 0).map((j) => [j, z[this.stretches[j].species]]);
1282
+ if (this.carriers.includes(k)) charged.push([X + this.carriers.indexOf(k), z[model.ports[k - 2].terminal]]);
1283
+ fed.filter((j) => z[this.stretches[j].species] === 0).forEach(unit);
1284
+ for (let a = 1; a < charged.length; a++) {
1285
+ const row = new Float64Array(C);
1286
+ row[charged[0][0]] = 1 / charged[0][1];
1287
+ row[charged[a][0]] = -1 / charged[a][1];
1288
+ rows.push(row);
1289
+ }
1013
1290
  });
1014
- // Reduced row echelon form; the free columns give the null space.
1291
+ // Reduced row echelon form; the free columns give the null space. Immobile stretches' columns
1292
+ // go first, so that a combination of them alone (trap states, X⁰ + X⁻) comes out on its own,
1293
+ // to be kept node by node, apart from any that moves (e⁻ − X⁰, the electrons the traps hold).
1294
+ const order = [...Array(C).keys()].sort((a, b) => (b < S && this._still(this.stretches[b])) - (a < S && this._still(this.stretches[a])));
1015
1295
  const pivots = [];
1016
1296
  let r = 0;
1017
- for (let col = 0; col < C && r < rows.length; col++) {
1297
+ for (const col of order) {
1298
+ if (r >= rows.length) break;
1018
1299
  let best = r;
1019
1300
  for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
1020
1301
  if (Math.abs(rows[best][col]) < 1e-9) continue;
@@ -1040,8 +1321,10 @@ export class Solver {
1040
1321
  const v = -rows[i][free];
1041
1322
  w[col] = Math.abs(v) < 1e-9 ? 0 : v;
1042
1323
  });
1324
+ w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
1043
1325
  basis.push(w);
1044
1326
  }
1327
+ basis.skipped = skipped;
1045
1328
  return basis;
1046
1329
  }
1047
1330
 
@@ -1056,39 +1339,19 @@ export class Solver {
1056
1339
  // sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
1057
1340
  // (identity row) there instead, and _solveBordered restores the constraint.
1058
1341
  _applyConstraints() {
1059
- const { n, M, res, c, dA: d } = this;
1060
- const vol = this.model.grid.vol;
1342
+ const { M, res } = this;
1061
1343
  for (const cs of this.constraints) {
1062
- let amount = 0, reference = 0, len = 0;
1063
- for (const { stretch, w } of cs.parts) {
1064
- const st = this.stretches[stretch], i = st.species;
1065
- reference += w * this.referenceAmounts[stretch];
1066
- for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
1067
- amount += w * vol[g] * c[g * n + i];
1068
- this._dc(g, i, d);
1069
- const b = this.blockOfNode[g];
1070
- for (let r = 0; r < M; r++) {
1071
- if (d[r] === 0 || this.loc[b * M + r] < 0) continue;
1072
- cs.idx[len] = this.rix[b * M + r];
1073
- cs.w[len++] = w * vol[g] * d[r];
1074
- }
1075
- }
1076
- }
1077
- // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
1078
- for (const { col, w } of cs.surface) {
1079
- const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
1080
- reference += w * this.surfaceRef[col];
1081
- port.nodes.forEach((g, j) => {
1082
- const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
1083
- amount += f * tq;
1084
- for (let x = 0; x < ns; x++) {
1085
- cs.idx[len] = this.rix[b * M + 1 + n + x];
1086
- cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
1087
- }
1088
- });
1344
+ const { amount, reference, dV } = this._lawAmount(cs);
1345
+ cs.res = amount - (cs.terminals.length > 0 ? (this.lawRefs.get(cs.key) ?? amount) : reference);
1346
+ if (cs.circuit >= 0) {
1347
+ // In place of the terminal's circuit row (its I = 0).
1348
+ const C = this.termC[cs.circuit];
1349
+ C.fill(0);
1350
+ for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += cs.w[j];
1351
+ this.termRes[cs.circuit] = cs.res;
1352
+ this.termDI[cs.circuit] = dV;
1353
+ continue;
1089
1354
  }
1090
- cs.len = len;
1091
- cs.res = amount - reference;
1092
1355
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
1093
1356
  this._replaceRow(b0, r0);
1094
1357
  this._j(b0, r0, b0, r0, 1);
@@ -1096,6 +1359,122 @@ export class Solver {
1096
1359
  }
1097
1360
  }
1098
1361
 
1362
+ // A conserved combination's amount as the state holds it (and its reference, closed), with its
1363
+ // derivatives in cs.idx/w (and dV, by the voltage of a terminal whose electrode's charge it
1364
+ // counts).
1365
+ _lawAmount(cs) {
1366
+ const { n, M, c, dA: d } = this;
1367
+ const vol = this.model.grid.vol;
1368
+ let amount = 0, reference = 0, len = 0, gross = 0;
1369
+ for (const { stretch, w } of cs.parts) {
1370
+ const st = this.stretches[stretch], i = st.species;
1371
+ if (cs.terminals.length === 0) reference += w * this.referenceAmounts[stretch];
1372
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
1373
+ amount += w * vol[g] * c[g * n + i];
1374
+ gross += Math.abs(w * vol[g] * c[g * n + i]);
1375
+ if (this.nodeConductor[g] >= 0) {
1376
+ // A metal's carriers: the sheet at a charged face, vol·c = ±D_f·A/(zF), held by the
1377
+ // face's displacement.
1378
+ const f = this.sheetFace[g];
1379
+ if (f >= 0 && this.loc[this.blockOfFace[f] * M] >= 0) {
1380
+ cs.idx[len] = this.rix[this.blockOfFace[f] * M];
1381
+ cs.w[len++] = (w * this.sheetSign[g] * this.model.grid.area[g]) / (this.z[i] * FARADAY);
1382
+ }
1383
+ continue;
1384
+ }
1385
+ this._dc(g, i, d);
1386
+ const b = this.blockOfNode[g];
1387
+ for (let r = 0; r < M; r++) {
1388
+ if (d[r] === 0 || this.loc[b * M + r] < 0) continue;
1389
+ cs.idx[len] = this.rix[b * M + r];
1390
+ cs.w[len++] = w * vol[g] * d[r];
1391
+ }
1392
+ }
1393
+ }
1394
+ // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
1395
+ for (const { col, w } of cs.surface) {
1396
+ const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
1397
+ if (cs.terminals.length === 0) reference += w * this.surfaceRef[col];
1398
+ port.nodes.forEach((g, j) => {
1399
+ const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
1400
+ amount += f * tq;
1401
+ gross += Math.abs(f * tq);
1402
+ for (let x = 0; x < ns; x++) {
1403
+ cs.idx[len] = this.rix[b * M + 1 + n + x];
1404
+ cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
1405
+ }
1406
+ });
1407
+ }
1408
+ // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
1409
+ // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as _portCapacitance has it).
1410
+ let dV = 0;
1411
+ for (const { k, w } of cs.carriers) {
1412
+ const port = this.model.ports[k - 2];
1413
+ if (!port.capacitance) continue;
1414
+ const f = w / (this.z[port.terminal] * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k];
1415
+ const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
1416
+ port.nodes.forEach((g, j) => {
1417
+ if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
1418
+ const b = this.blockOfNode[g], s = vol[g] * this.portArea[k - 2][j] * Cs;
1419
+ amount += f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M]));
1420
+ gross += Math.abs(f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M])));
1421
+ dV += f * s;
1422
+ cs.idx[len] = this.rix[b * M];
1423
+ cs.w[len++] = -f * s * this.VT;
1424
+ });
1425
+ }
1426
+ cs.len = len;
1427
+ return { amount, reference, dV, gross };
1428
+ }
1429
+
1430
+ // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
1431
+ _lawStart(cs) {
1432
+ const { n, nSurf } = this, vol = this.model.grid.vol;
1433
+ let a = 0;
1434
+ for (const { stretch, w } of cs.parts) {
1435
+ const st = this.stretches[stretch], i = st.species;
1436
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * this.cOld[g * n + i];
1437
+ }
1438
+ for (const { col, w } of cs.surface) {
1439
+ const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity;
1440
+ port.nodes.forEach((g, j) => (a += w * G * vol[g] * this.portArea[k][j] * this.thOld[g * nSurf + q]));
1441
+ }
1442
+ 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);
1443
+ return a;
1444
+ }
1445
+
1446
+ // Time steps: a terminal driven by a current that alone feeds a conserved combination can read
1447
+ // its current as the combination's change, F (S − S*)/(λ dt), which the step's balance rows sum
1448
+ // to exactly, rather than as the flux through it. Both are exact; they differ in round-off and
1449
+ // in what Newton can see. Through a metal, the flux is G Δη, its two η all but equal: a floating
1450
+ // gate's charging current fell below that at steps of ~0.03 s, and the step came out singular.
1451
+ // Over long steps the change is the better kept (a closed host at open circuit, read by its
1452
+ // flux, drifted from its charge by 3 mV over 5e11 s, and its steps failed by 4e12 s). Over
1453
+ // short ones it's the amount's round-off over dt (a battery's cathode holds 0.04 mol/m² of
1454
+ // electrons: 1e-6 A/m² at steps of 1e-6 s, enough to reject steps at tol 1e-8), where the flux,
1455
+ // its levels carried in two words (u + uLo), is far cleaner than ε G. So a step reads the change
1456
+ // where that's ε·F·S/(λ dt) below 1e-6 of the flux's ε G, as its start has them, keeps to it
1457
+ // throughout, and where a step read by the flux fails, it's tried once more read by the change
1458
+ // (see step).
1459
+ _circuitLaws(dt) {
1460
+ for (const cs of [...this.constraints, ...this.timeLaws]) {
1461
+ const k = cs.circuit;
1462
+ if (k < 0 || !this.floating.includes(k)) continue;
1463
+ const { amount, dV, gross } = this._lawAmount(cs), f = FARADAY / (cs.lambda * dt), C = this.termC[k];
1464
+ if (!this.lawForm.has(cs)) {
1465
+ let G = 0;
1466
+ for (let j = 0; j < C.length; j++) G = Math.max(G, Math.abs(C[j]));
1467
+ this.lawForm.set(cs, Math.abs(f) * gross < 1e-6 * G);
1468
+ }
1469
+ if (!this.lawForm.get(cs)) continue;
1470
+ C.fill(0);
1471
+ for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
1472
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1473
+ this.termRes[k] = f * (amount - this._lawStart(cs)) - (d.kind === 'I' ? src : (src - this.termV[k]) / d.R);
1474
+ this.termDI[k] = f * dV + (d.kind === 'I' ? 0 : 1 / d.R);
1475
+ }
1476
+ }
1477
+
1099
1478
  // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
1100
1479
  // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
1101
1480
  // the rows pinned in T: the spectators' conserved amounts (see _applyConstraints) and the
@@ -1114,10 +1493,11 @@ export class Solver {
1114
1493
  for (let a = 0; a < S; a++) {
1115
1494
  if (!cols[a]) cols[a] = new Float64Array(N + 1);
1116
1495
  if (a < P) {
1117
- const row = this.rix[pins[a].row];
1118
- e[row] = 1;
1496
+ // The response to a unit pin, or where the pin moves a flat stretch, to its shift.
1497
+ const col = pins[a].col ?? [pins[a].row];
1498
+ for (const o of col) e[this.rix[o]] = 1;
1119
1499
  this._solveLinear(e, cols[a]);
1120
- e[row] = 0;
1500
+ for (const o of col) e[this.rix[o]] = 0;
1121
1501
  } else this._solveLinear(this.termB[fl[a - P]], cols[a]);
1122
1502
  }
1123
1503
  // Row a of the small system, as a dot product with a compact vector (sparse for pins).
@@ -1194,13 +1574,72 @@ export class Solver {
1194
1574
  // change of source is a discontinuity, so time stepping restarts its order.
1195
1575
  redrive() {
1196
1576
  this.atSteady = false;
1197
- const before = this.floating;
1577
+ const before = this.floating, driven = this.drivenKey;
1198
1578
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
1199
1579
  if (this.floating.length !== before.length) this.deltaV = null;
1580
+ // A terminal newly driven by a current (or no longer) can close a combination behind it.
1581
+ // (and one put behind a resistance, or taken from it, its time steps' reading: see _laws)
1582
+ this.drivenKey = this._drivenKey();
1583
+ if (this.drivenKey !== driven) this._laws();
1200
1584
  this._refreshSources();
1201
1585
  this.history = [];
1202
1586
  }
1203
1587
 
1588
+ // After a steady solve: each reaction that alone changes some combination nothing else feeds
1589
+ // (see _laws), whose total rate a steady state therefore sets to zero, but whose affinity has one
1590
+ // sign wherever it acts, so that its total can't be zero. Too slow to show, its rate lost to
1591
+ // round-off, it was left where the solve began: a slow complexation 43 mV off, an electrode
1592
+ // port's slow couple at its starting composition, each converged without a word. (A face
1593
+ // reaction stands on its affinity, so it's only caught here if that couldn't be arranged.)
1594
+ stuckReactions() {
1595
+ const { n, M, u, uLo, model } = this, grid = model.grid, S = this.stretches.length, X = S + this.surfCols.length, out = [];
1596
+ const passes = (k) => !this._noCurrent(this.terms[k]);
1597
+ const fed = (w) => w.some((x, k) => x !== 0 && ((k >= X && passes(this.carriers[k - X])) || (k < S && this.terms.some((t, j) => t.drive.kind === 'I' && passes(j) && this._feeds(j, this.stretches[k])))));
1598
+ const needed = (rx) => {
1599
+ const basis = this._conservedMoieties(true, [], rx);
1600
+ return basis.some((w) => !fed(w) && basis.skipped.some((row) => Math.abs(row.reduce((t, x, k) => t + x * w[k], 0)) > 1e-9));
1601
+ };
1602
+ const eta = (g, slot) => u[this.blockOfNode[g] * M + slot] + uLo[this.blockOfNode[g] * M + slot];
1603
+ const report = (where, as) => {
1604
+ if (as.length === 0 || !(as.every((a) => a > 1e-6) || as.every((a) => a < -1e-6))) return;
1605
+ out.push({ where, affinity: as.reduce((m, a) => (Math.abs(a) < Math.abs(m) ? a : m)) });
1606
+ };
1607
+ model.interfaces.forEach((itf, f) => {
1608
+ const gL = grid.regionEnd[f], gR = grid.regionStart[f + 1];
1609
+ itf.reactions.forEach((rx, q) => {
1610
+ if (this.affinities.some((af) => af.rx === rx) || !needed(rx)) return;
1611
+ report(`interfaces[${f}].reactions[${q}]`, [rx.part.reduce((a, p) => a - p.nu * eta(p.side ? gR : gL, 1 + p.i), rx.fixedA)]);
1612
+ });
1613
+ });
1614
+ model.reactions.forEach((mrx, x) => {
1615
+ const rx = this.rxs[x];
1616
+ if (rx.generation || !needed(mrx)) return;
1617
+ const as = [];
1618
+ for (let g = 0; g < this.nNodes; g++) if (rx.kfNode[g] > 0 || rx.srh?.[this.nodeMaterial[g]]) as.push(rx.sp.reduce((a, i, p) => a + rx.nu[p] * eta(g, 1 + i), rx.fixedA));
1619
+ report(`bulkReactions[${x}]`, as);
1620
+ });
1621
+ model.ports.forEach((port, k) => {
1622
+ port.reactions.forEach((rx, q) => {
1623
+ if (!needed(rx)) return;
1624
+ let aV = rx.fixedA;
1625
+ for (const p of rx.part) if (p.side === 1) aV -= p.nu * this.portEta(port, p.i);
1626
+ report(`ports[${k}].reactions[${q}]`, port.nodes.map((g) => rx.part.reduce((a, p) => (p.side === 1 ? a : a - p.nu * eta(g, p.side === 2 ? 1 + n + p.s : 1 + p.i)), aV)));
1627
+ });
1628
+ });
1629
+ return out;
1630
+ }
1631
+
1632
+ // How each terminal is driven, as far as the laws care (see _laws): by a voltage or a current,
1633
+ // behind a resistance or not, and a current that's held at zero throughout (which feeds a
1634
+ // combination nothing, so a reaction's affinity may stand in for its summed balance).
1635
+ _drivenKey() {
1636
+ return this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}${this._noCurrent(t) ? '0' : ''}`).join();
1637
+ }
1638
+
1639
+ _noCurrent(t) {
1640
+ return t.drive.kind === 'I' && !(t.drive.R > 0) && t.drive.src.value === 0;
1641
+ }
1642
+
1204
1643
  // Held terminals take their sources' values at sourceTime (a floating one keeps its voltage,
1205
1644
  // an unknown; one behind a resistance reads its source voltage in _circuit).
1206
1645
  _refreshSources() {
@@ -1355,11 +1794,15 @@ export class Solver {
1355
1794
  if (!mat.phiFree && !responds && mat.ideal && electrode) {
1356
1795
  // Nor here, but an electrode spread through it does: start with the port's first reaction
1357
1796
  // that can be balanced at equilibrium with the port's level (the electrode at open circuit).
1797
+ const bare = 1 - electrode.surface.reduce((t, sp) => t + sp.theta0, 0);
1358
1798
  for (const rx of electrode.reactions) {
1359
1799
  let a = rx.fixedA, s = 0;
1360
1800
  for (const p of rx.part) {
1361
1801
  if (p.side === 1) a -= p.nu * this.portEta(electrode, p.i);
1362
- else if (mode[p.i] === 2) {
1802
+ else if (p.side === 2) {
1803
+ const sp = electrode.surface[p.s]; // (at its starting coverage)
1804
+ a -= p.nu * (sp.mu0 / model.RT + Math.log(sp.theta0 / bare));
1805
+ } else if (mode[p.i] === 2) {
1363
1806
  a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
1364
1807
  s += p.nu * z[p.i];
1365
1808
  } else s = NaN;
@@ -1404,6 +1847,31 @@ export class Solver {
1404
1847
  }
1405
1848
  }
1406
1849
  }
1850
+ // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
1851
+ // splits between its ion and its electrons. Start its ion level with the same ion's beside it
1852
+ // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
1853
+ // and a floating contact on them, start where the cell's open-circuit voltage puts them, not
1854
+ // volts away. (A shift of φ̂ and each η by z·Δ: the concentrations don't change.)
1855
+ regions.forEach((reg, r) => {
1856
+ const mat = materials[reg.material];
1857
+ if (!mat.phiFree) return;
1858
+ const sides = [[r - 1, grid.regionStart[r], r > 0 ? grid.regionEnd[r - 1] : -1], [r + 1, grid.regionEnd[r], r + 1 < regions.length ? grid.regionStart[r + 1] : -1]];
1859
+ for (const [nr, gIn, gOut] of sides) {
1860
+ if (gOut < 0) continue;
1861
+ const other = materials[regions[nr].material];
1862
+ if (other.phiFree || other.conductor) continue;
1863
+ const i = z.findIndex((zi, j) => zi !== 0 && mat.present[j] && other.present[j]);
1864
+ if (i < 0) continue;
1865
+ const d = (u[this.blockOfNode[gOut] * M + 1 + i] - u[this.blockOfNode[gIn] * M + 1 + i]) / z[i];
1866
+ if (!Number.isFinite(d)) continue;
1867
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
1868
+ const b = this.blockOfNode[g];
1869
+ u[b * M] += d;
1870
+ for (let j = 0; j < n; j++) if (mat.present[j]) u[b * M + 1 + j] += z[j] * d;
1871
+ }
1872
+ return;
1873
+ }
1874
+ });
1407
1875
  // A floating contact facing a held one starts where the start's own composition puts it: the
1408
1876
  // held voltage plus the difference between the two terminal species' levels beside each
1409
1877
  // contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
@@ -1652,6 +2120,7 @@ export class Solver {
1652
2120
  res.fill(0);
1653
2121
  this.dtNow = dt;
1654
2122
  if (this.lin) this.lin.reset(1 / dt);
2123
+ if (this.steady && dt === Infinity) this._snapFlat();
1655
2124
  this.computeConcentrations();
1656
2125
 
1657
2126
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -1683,13 +2152,16 @@ export class Solver {
1683
2152
  // Terminals: ports (after every other term at their nodes, so a held level can read its
1684
2153
  // flux), then contacts, then the circuit rows of the floating ones.
1685
2154
  this._terminals(dt);
1686
- if (this.flattening && dt === Infinity) this._flatRows();
1687
- if (this.flattening && this.constrained && dt === Infinity) this._levelRows();
1688
- if (this.constrained && dt === Infinity && this.constraints.length > 0) this._applyConstraints();
1689
- if (this.constrained && dt === Infinity && this.localConstraints.length > 0) this._applyLocalConstraints();
1690
- // Rows kept aside for _solveBordered: the conserved amounts' and the islands'.
1691
- this.pinnedIslands = this.constrained && dt === Infinity && this.islandsOn ? this._applyIslands() : [];
1692
- this.pins = this.constrained && dt === Infinity ? [...this.constraints, ...this.pinnedIslands] : [];
2155
+ if (Number.isFinite(dt) && (this.constraints.length > 0 || this.timeLaws.length > 0)) this._circuitLaws(dt);
2156
+ // The steady system's own rows (see _laws): levels held flat, conserved amounts, islands'
2157
+ // balances, the last two kept aside for _solveBordered.
2158
+ const steady = this.steady && dt === Infinity;
2159
+ if (steady) this._flatRows();
2160
+ if (steady && this.affinities.length > 0) this._applyAffinities();
2161
+ if (steady && this.constraints.length > 0) this._applyConstraints();
2162
+ if (steady && this.localConstraints.length > 0) this._applyLocalConstraints();
2163
+ this.pinnedIslands = steady && this.islandsOn ? this._applyIslands() : [];
2164
+ this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.affinities, ...this.pinnedIslands] : [];
1693
2165
  this.transformed = this.combining && dt !== Infinity;
1694
2166
  if (this.transformed) this._chargeRows(dt);
1695
2167
  }
@@ -2536,7 +3008,8 @@ export class Solver {
2536
3008
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
2537
3009
  const deta = target - (u[o] + uLo[o]); // (μ̄_out − μ̄)/RT
2538
3010
  if (link.type === 'equilibrium') {
2539
- if (g === 0 || g === this.nNodes - 1) continue; // a device end node's level is its contact's business
3011
+ // a device end node's level is its contact's business, where the contact links it
3012
+ if ((g === 0 && this.model.contacts.left.species[i].type !== 'blocked') || (g === this.nNodes - 1 && this.model.contacts.right.species[i].type !== 'blocked')) continue;
2540
3013
  flux[i] += res[R[o]];
2541
3014
  this._captureRow(b, r, zF, C);
2542
3015
  this._replaceRow(b, r);
@@ -2678,6 +3151,9 @@ export class Solver {
2678
3151
  const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2679
3152
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2680
3153
  const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
3154
+ // Its current, the charging (Q − Q at the step's start)/dt; or, steady at no current, the
3155
+ // charge it keeps in place of that (see _captureLaws).
3156
+ const rate = Number.isFinite(dt) ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
2681
3157
  let Q = 0, dQdV = 0;
2682
3158
  port.nodes.forEach((g, w) => {
2683
3159
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
@@ -2690,12 +3166,12 @@ export class Solver {
2690
3166
  res[R[b * M]] -= q;
2691
3167
  this._j(b, 0, b, 0, s * VT);
2692
3168
  B[R[b * M]] -= s;
2693
- if (Number.isFinite(dt)) Ct[R[b * M]] += (-s * VT) / dt;
3169
+ if (rate > 0) Ct[R[b * M]] += -s * VT * rate;
2694
3170
  });
2695
3171
  this.portQ[k - 2] = Q;
2696
- if (Number.isFinite(dt)) {
2697
- this.termI[k] += (Q - this.portQStart[k - 2]) / dt;
2698
- this.termDI[k] += dQdV / dt;
3172
+ if (rate > 0) {
3173
+ this.termI[k] += (Q - (dt < Infinity ? this.portQStart[k - 2] : this.chargeRefs.get(k))) * rate;
3174
+ this.termDI[k] += dQdV * rate;
2699
3175
  }
2700
3176
  }
2701
3177
 
@@ -2816,10 +3292,13 @@ export class Solver {
2816
3292
  this._j(b, 0, b, 0, Ac * link.C * VT);
2817
3293
  B[R[b * M]] += -Ac * link.C;
2818
3294
  this.contactD[side] = sgn * Din;
2819
- if (dyn) {
2820
- I += (Din - Dstart) / dt;
2821
- C[R[b * M]] += (-Ac * link.C * VT) / dt;
2822
- this.termDI[k] += (Ac * link.C) / dt;
3295
+ // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
3296
+ // the charge it keeps in place of that (see _captureLaws).
3297
+ const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
3298
+ if (rate > 0) {
3299
+ I += (Din - (dyn ? Dstart : this.chargeRefs.get(k))) * rate;
3300
+ C[R[b * M]] += -Ac * link.C * VT * rate;
3301
+ this.termDI[k] += Ac * link.C * rate;
2823
3302
  }
2824
3303
  } else if (link.type === 'pinned' || link.type === 'bulk') {
2825
3304
  // The Poisson residual is the outside's charge, D_in.
@@ -2828,6 +3307,9 @@ export class Solver {
2828
3307
  if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2829
3308
  I += (Din - Dstart) / dt;
2830
3309
  this._captureRow(b, 0, 1 / dt, C);
3310
+ } else if (!dyn && this.steady && this.chargeRefs.has(k)) {
3311
+ I += Din - this.chargeRefs.get(k);
3312
+ this._captureRow(b, 0, 1, C);
2831
3313
  }
2832
3314
  this._replaceRow(b, 0);
2833
3315
  if (link.type === 'pinned') {
@@ -2860,12 +3342,42 @@ export class Solver {
2860
3342
 
2861
3343
  // Steady solves: each flat stretch's level held at its contact's throughout (see
2862
3344
  // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
3345
+ // A flat stretch's level: its contact's, through the terminal k that holds it, or (reaching
3346
+ // none) its first node's, which its amount fixes (that row is the amount's, a pin whose
3347
+ // response moves the whole stretch: see _applyConstraints).
3348
+ _flatLevel(st) {
3349
+ const { M, u, uLo, z, VT, model } = this, i = st.species;
3350
+ if (st.leftOpen || st.rightOpen) {
3351
+ const side = st.leftOpen ? 'left' : 'right', link = model.contacts[side].species[i], k = side === 'left' ? 0 : 1;
3352
+ return { k, hi: z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT, lo: 0 };
3353
+ }
3354
+ const o = this.blockOfNode[st.nodes[0]] * M + 1 + i;
3355
+ return { k: -1, hi: u[o], lo: uLo[o] };
3356
+ }
3357
+
3358
+ // Each flat stretch's nodes set to its first node's level exactly (where Newton's damped
3359
+ // updates have taken it, on the way to its contact's), so that it carries exactly no flux (the
3360
+ // expm1 form of a zero difference). Flat only to round-off, a level moved by its contact had
3361
+ // read as a current: a MOS capacitor's holes, their contact jumped to −6 V, as
3362
+ // 3e-4 A/m² through a bulk that conducts ~1e10 S/m² (the true current was 3.5e-7).
3363
+ _snapFlat() {
3364
+ const { M, u, uLo } = this;
3365
+ for (const st of this.flatStretches) {
3366
+ const f = this.blockOfNode[st.nodes[0]] * M + 1 + st.species, hi = u[f], lo = uLo[f];
3367
+ for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
3368
+ const o = this.blockOfNode[g] * M + 1 + st.species;
3369
+ if (this.loc[o] < 0) continue;
3370
+ u[o] = hi;
3371
+ uLo[o] = lo;
3372
+ }
3373
+ }
3374
+ }
3375
+
2863
3376
  _flatRows() {
2864
3377
  const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
2865
3378
  for (const st of this.flatStretches) {
2866
- const i = st.species, side = st.leftOpen ? 'left' : 'right', k = side === 'left' ? 0 : 1;
2867
- const link = model.contacts[side].species[i];
2868
- const level = z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT;
3379
+ const i = st.species, open = st.leftOpen || st.rightOpen;
3380
+ const { k, hi, lo } = this._flatLevel(st), level = hi + lo;
2869
3381
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
2870
3382
  const b = this.blockOfNode[g], o = b * M + 1 + i;
2871
3383
  if (this.loc[o] < 0) continue;
@@ -2873,7 +3385,7 @@ export class Solver {
2873
3385
  for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
2874
3386
  this._j(b, 1 + i, b, 1 + i, 1);
2875
3387
  res[R[o]] = u[o] + uLo[o] - level;
2876
- if (z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
3388
+ if (open && z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
2877
3389
  }
2878
3390
  // A face inside the stretch whose link holds the level continuous carries the species' flux
2879
3391
  // as an unknown that only the edge balances, now replaced, determined: it's zero.
@@ -2889,24 +3401,32 @@ export class Solver {
2889
3401
  }
2890
3402
  }
2891
3403
 
2892
- // Zero one row of the Jacobian (all three blocks), ready to be replaced.
2893
- // Steady solves: each level stretch's levels held equal (see levelStretches), node to node in
2894
- // place of the balances. The first node's row is the amount's (see _applyConstraints).
2895
- _levelRows() {
2896
- const { M, u, uLo, res } = this, R = this.rix;
2897
- for (const st of this.levelStretches) {
2898
- const i = st.species;
2899
- for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
2900
- const b = this.blockOfNode[g], o = b * M + 1 + i, p = o - M;
2901
- if (this.loc[o] < 0 || this.loc[p] < 0) continue;
2902
- this._replaceRow(b, 1 + i);
2903
- for (const B of this.termB) B[R[o]] = 0;
2904
- this._j(b, 1 + i, b, 1 + i, 1);
2905
- this._j(b, 1 + i, b - 1, 1 + i, -1);
2906
- res[R[o]] = u[o] + uLo[o] - (u[p] + uLo[p]);
3404
+ // Steady solves: each reaction that alone changes a combination (see _laws) held at its
3405
+ // equilibrium, a = fixedA − Σ ν η = 0, in place of one of that combination's balance rows (a pin,
3406
+ // like an amount's).
3407
+ _applyAffinities() {
3408
+ const { M, u, uLo, res } = this, grid = this.model.grid;
3409
+ for (const af of this.affinities) {
3410
+ const b0 = Math.floor(af.row / M), r0 = af.row % M;
3411
+ this._replaceRow(b0, r0);
3412
+ this._j(b0, r0, b0, r0, 1);
3413
+ res[this.rix[af.row]] = 0;
3414
+ const gL = grid.regionEnd[af.f], gR = grid.regionStart[af.f + 1];
3415
+ let hi = af.rx.fixedA, lo = 0, len = 0;
3416
+ for (const p of af.rx.part) {
3417
+ const o = this.blockOfNode[p.side ? gR : gL] * M + 1 + p.i;
3418
+ hi -= p.nu * u[o];
3419
+ lo -= p.nu * uLo[o];
3420
+ if (this.loc[o] < 0) continue;
3421
+ af.idx[len] = this.rix[o];
3422
+ af.w[len++] = -p.nu;
2907
3423
  }
3424
+ af.len = len;
3425
+ af.res = hi + lo;
2908
3426
  }
2909
3427
  }
3428
+
3429
+ // Zero one row of the Jacobian (all three blocks), ready to be replaced.
2910
3430
  _replaceRow(b, rs) {
2911
3431
  const l = this.loc[b * this.M + rs];
2912
3432
  if (l < 0) return;
@@ -2994,6 +3514,11 @@ export class Solver {
2994
3514
  rest.multiply(v, jv);
2995
3515
  if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
2996
3516
  lin.apply(v, jv, 1, 1 / dt);
3517
+ // A flat stretch's rows hold each level to its first node's (see _flatRows).
3518
+ for (const p of pins) {
3519
+ if (!p.col) continue;
3520
+ for (const o of p.col) if (o !== p.row && this.loc[o] >= 0) jv[R[o]] -= x[R[p.row]];
3521
+ }
2997
3522
  const out = new Float64Array(N + K);
2998
3523
  for (let j = 0; j < N; j++) {
2999
3524
  let t = jv[j];
@@ -3038,8 +3563,13 @@ export class Solver {
3038
3563
  // stops refining.
3039
3564
  const x0 = Float64Array.from(x);
3040
3565
  const ok = gmresReal(op, pb, x, { m: 8, restarts: 1, tol: 1e-8 });
3041
- let c = 0;
3042
- for (let j = 0; j < N + K; j++) c = Math.max(c, Math.abs(x[j] - x0[j]));
3566
+ // (Measured on the potentials, as Newton's own step is: a rate the plain solve left
3567
+ // unsolved, at a perturbed pivot, can need a correction of 41.7 mol/(m²·s).)
3568
+ const dx = new Float64Array(N + 1), dV = new Float64Array(K);
3569
+ for (let j = 0; j < N; j++) dx[j] = x[j] - x0[j];
3570
+ for (let a = 0; a < K; a++) dV[a] = x[N + a] - x0[N + a];
3571
+ if (this.transformed) this._untransform(dx);
3572
+ const c = this._maxPotentialStep(dx, dV);
3043
3573
  if (!ok || !(c < 1)) return false;
3044
3574
  for (let j = 0; j < N; j++) delta[j] = x[j];
3045
3575
  for (let a = 0; a < K; a++) deltaV[a] = x[N + a];
@@ -3091,9 +3621,21 @@ export class Solver {
3091
3621
  // A/m² of noise), their rows can throw Newton off instead: if it doesn't converge again
3092
3622
  // within a dozen iterations, the plain solution stands.
3093
3623
  this.islandsOn = islands;
3094
- let rounds = 0, kept = null;
3624
+ let rounds = 0, kept = null, perturbed = false;
3625
+ // Converged by a small update, but solved at a perturbed pivot, or in a steady solve (where a
3626
+ // factorisation that lost a row's digits left a reaction's rate 3.6e7 off it, the update
3627
+ // small, an I = 0 electrode at a voltage 0.4 V from its steady one): the residual itself,
3628
+ // assembled at the state, must be small too (each row scaled by its largest Jacobian entry).
3629
+ const unsolved = () => {
3630
+ if (!perturbed && dt !== Infinity) return false;
3631
+ this.assemble(dt);
3632
+ this._equilibrate();
3633
+ let r = 0;
3634
+ for (let k = 0; k < this.SINK; k++) r = Math.max(r, Math.abs(this.res[k]));
3635
+ return !(r < 1e-6);
3636
+ };
3095
3637
  const islandsOff = (it, result) => {
3096
- if (dt !== Infinity || !this.constrained || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3638
+ if (dt !== Infinity || !this.steady || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3097
3639
  this.islandsOn = true;
3098
3640
  rounds++;
3099
3641
  kept = { it, result, u: Float64Array.from(this.u), uLo: Float64Array.from(this.uLo), termV: Float64Array.from(this.termV) };
@@ -3136,6 +3678,13 @@ export class Solver {
3136
3678
  } catch (err) {
3137
3679
  return fail({ converged: false, iterations: it, history, error: err.message });
3138
3680
  }
3681
+ // A pivot perturbed (one that cancelled to exactly nothing) can leave a row unsolved, its
3682
+ // update small while its residual isn't: the same couple written twice on an electrode at
3683
+ // open circuit converged with one spelling's rate row 41.7 mol/(m²·s) off, the couple
3684
+ // 1.5 RT from equilibrium. Refine the solves (exact J·v) wherever one was, and before
3685
+ // taking a small update for convergence there, look at the residual itself (see unsolved).
3686
+ perturbed = this.sys.perturbedPivots > 0;
3687
+ if (perturbed && !noRefine) refine = true;
3139
3688
  try {
3140
3689
  const pins = this.pins;
3141
3690
  deltaV.fill(0);
@@ -3174,6 +3723,7 @@ export class Solver {
3174
3723
  const last = history[history.length - 2], theta = step / last;
3175
3724
  const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
3176
3725
  if (alpha === 1 && (step < tol || contracted)) {
3726
+ if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3177
3727
  const done = { converged: true, iterations: it, history, residual: rmax };
3178
3728
  if (islandsOff(it, done)) continue;
3179
3729
  this.computeConcentrations();
@@ -3194,6 +3744,7 @@ export class Solver {
3194
3744
  if (refine) refined++;
3195
3745
  else if (!noRefine && alpha === 1 && it >= 3 && step > 0.25 * p1 && (step < 1e-4 || (step > p1 && p1 > p2))) refine = true;
3196
3746
  if (alpha === 1 && it >= 4 && (!refine || refined >= 1 || noRefine) && step < floor && p1 < floor && step > 0.25 * p1 && p1 > 0.25 * p2) {
3747
+ if (unsolved()) return fail({ converged: false, iterations: it, history, error: 'a row left unsolved at a pivot that cancelled exactly' });
3197
3748
  const done = { converged: true, iterations: it, history, residual: rmax, roundoff: true };
3198
3749
  if (islandsOff(it, done)) continue;
3199
3750
  this.computeConcentrations();
@@ -3227,18 +3778,20 @@ export class Solver {
3227
3778
  }
3228
3779
  if (!(c.digits > (this.conditioning?.digits ?? -1))) return;
3229
3780
  const { grid } = this.model;
3230
- let x = NaN, where = '';
3781
+ let x = NaN, where = '', nodes = [];
3231
3782
  for (let g = 0; g < this.nNodes; g++) {
3232
3783
  if (this.blockOfNode[g] !== c.block) continue;
3233
3784
  x = grid.x[g];
3234
3785
  where = this.model.regions[grid.nodeRegion[g]].name;
3786
+ nodes = [g];
3235
3787
  }
3236
3788
  for (let f = 0; f < this.nFaces; f++) {
3237
3789
  if (this.blockOfFace[f] !== c.block) continue;
3238
3790
  x = grid.x[grid.regionEnd[f]];
3239
3791
  where = `interfaces[${f}]`;
3792
+ nodes = [grid.regionEnd[f], grid.regionEnd[f] + 1];
3240
3793
  }
3241
- this.conditioning = { digits: c.digits, x, where };
3794
+ this.conditioning = { digits: c.digits, x, where, nodes };
3242
3795
  }
3243
3796
 
3244
3797
  /**
@@ -3301,11 +3854,7 @@ export class Solver {
3301
3854
  this.portQEnd = Float64Array.from(this.portQ);
3302
3855
  }
3303
3856
  const DN = { ...this.contactDEnd }, QN = Float64Array.from(this.portQEnd);
3304
- if (opts.guess) {
3305
- // Start Newton from a predicted state (e.g. extrapolated from the history).
3306
- this.u.set(opts.guess);
3307
- this.uLo.fill(0);
3308
- }
3857
+ if (opts.guess) this._guess(opts.guess); // start Newton from a predicted state
3309
3858
  let dtEff = dt;
3310
3859
  if (bdf) {
3311
3860
  const a0 = (1 + 2 * w) / (1 + w), b1 = (1 + w) / a0, b2 = (w * w) / (1 + w) / a0;
@@ -3328,26 +3877,35 @@ export class Solver {
3328
3877
  // Implicit: sources at the step's end, as seen from within the step (before any jump there).
3329
3878
  // A step landing on a breakpoint ends on it exactly.
3330
3879
  const tEnd = this.landing !== undefined && Math.abs(this.time + dt - this.landing) <= 1e-9 * dt ? this.landing : this.time + dt;
3331
- this.sourceTime = this.steady ? this.time : tEnd;
3880
+ this.sourceTime = this.steady ? this.steadyAt : tEnd;
3332
3881
  this.sourceBefore = !this.steady;
3333
3882
  this.combining = true;
3883
+ this.lawForm = new Map(); // (see _circuitLaws)
3334
3884
  let result;
3335
3885
  try {
3336
3886
  result = this.newton(dtEff, opts);
3337
- if (!result.converged && dtEff === Infinity && this.constrained && this.islands.length > 0) {
3887
+ if (!result.converged && dtEff === Infinity && this.steady && this.islands.length > 0) {
3338
3888
  // Plain solves that don't converge may be lost along an island's level (two regions
3339
3889
  // conducting 1e20 times less hold it): once more from the start, with the islands'
3340
3890
  // summed rows in throughout (see newton).
3341
3891
  this.u.set(this.uPrev);
3342
3892
  this.uLo.set(this.uPrevLo);
3343
3893
  this.termV.set(this.termVPrev);
3344
- if (opts.guess) {
3345
- this.u.set(opts.guess);
3346
- this.uLo.fill(0);
3347
- }
3894
+ if (opts.guess) this._guess(opts.guess);
3348
3895
  const again = this.newton(dtEff, { ...opts, islands: true });
3349
3896
  result = { ...again, iterations: result.iterations + again.iterations };
3350
3897
  }
3898
+ if (!result.converged && Number.isFinite(dtEff) && [...this.lawForm.values()].some((v) => !v)) {
3899
+ // A terminal read by its flux whose response is lost to round-off (a floating gate's
3900
+ // charging, below its metal's G Δη): once more, read by the change (see _circuitLaws).
3901
+ this.u.set(this.uPrev);
3902
+ this.uLo.set(this.uPrevLo);
3903
+ this.termV.set(this.termVPrev);
3904
+ if (opts.guess) this._guess(opts.guess);
3905
+ for (const cs of this.lawForm.keys()) this.lawForm.set(cs, true);
3906
+ const again = this.newton(dtEff, opts);
3907
+ result = { ...again, iterations: result.iterations + again.iterations };
3908
+ }
3351
3909
  } finally {
3352
3910
  this.combining = false;
3353
3911
  }
@@ -3360,17 +3918,35 @@ export class Solver {
3360
3918
  this.contactDOld = this.contactDStart;
3361
3919
  this.portQOld = this.portQStart;
3362
3920
  this._accumulateBoundaryIntake(dtEff, cN);
3921
+ // (In time, the state is no longer the steady solve's that a continuation would start from.)
3922
+ if (!this.steady) {
3923
+ this.solvedV = undefined;
3924
+ this.stepped = true;
3925
+ }
3363
3926
  } else {
3364
3927
  this.u.set(this.uPrev);
3365
3928
  this.uLo.set(this.uPrevLo);
3366
3929
  this.termV.set(this.termVPrev);
3367
- this.sourceTime = this.time;
3930
+ this.sourceTime = this.steady ? this.steadyAt : this.time;
3368
3931
  this.computeConcentrations();
3369
3932
  }
3370
3933
  this.sourceBefore = false;
3371
3934
  return result;
3372
3935
  }
3373
3936
 
3937
+ // Newton's unknowns set to a guess (e.g. extrapolated from the history). Only its unknowns: the
3938
+ // other slots never change (see _activeSlots), and among them is a gauge, an insertion host's
3939
+ // φ̂, that nothing pulls back. Extrapolated through steps that grow, it wandered off
3940
+ // geometrically, 147 to 8000 thermal units in a battery's first 100 s and 1e11 by 1e5 s, where
3941
+ // its species' levels (η − zφ̂, summed back by the host's statistics) lost their digits to it.
3942
+ _guess(guess) {
3943
+ const { u, uLo } = this;
3944
+ for (const k of this.fullOf) {
3945
+ u[k] = guess[k];
3946
+ uLo[k] = 0;
3947
+ }
3948
+ }
3949
+
3374
3950
  /**
3375
3951
  * Adaptive time stepping to tEnd: variable-step BDF2 (or backward Euler), with the local error
3376
3952
  * estimated against an explicit predictor through the previous states, and controlled to
@@ -3379,6 +3955,11 @@ export class Solver {
3379
3955
  * animation can call it once per frame; the step size carries over between calls.
3380
3956
  */
3381
3957
  integrate(tEnd, opts = {}) {
3958
+ // tEnd is an absolute time. Not a number at all is an error; a NaN (a first animation frame's
3959
+ // clock, or a time read from a field that doesn't exist) does nothing, and says so.
3960
+ const absolute = `tEnd is the time to reach in s, absolute (the device is at t = ${this.time}, which a solution gives as its time)`;
3961
+ if (typeof tEnd !== 'number') throw new SolverError(`advance(tEnd): ${absolute}; got ${tEnd}`);
3962
+ if (Number.isNaN(tEnd)) return { converged: true, done: false, steps: 0, rejected: 0, iterations: 0, trace: { t: [], current: [], voltage: [] }, stopped: `advance(NaN) did nothing: ${absolute}` };
3382
3963
  const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
3383
3964
  const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
3384
3965
  const start = clock();
@@ -3388,6 +3969,7 @@ export class Solver {
3388
3969
  // Already there (an animation frame with no time to add): nothing to do, and the step size
3389
3970
  // carried to the next call stays as it was.
3390
3971
  if (!(tEnd > this.time)) return { converged: true, done: true, steps: 0, rejected: 0, iterations: 0, trace };
3972
+ const t0 = this.time;
3391
3973
  let dt = this.dtNext ?? opts.dt0 ?? (tEnd - this.time) * 1e-4;
3392
3974
  if (!(dt > 0)) dt = (tEnd - this.time) * 1e-4;
3393
3975
  // The shortest step worth trying: round-off relative to the time, or the device's fastest
@@ -3400,8 +3982,10 @@ export class Solver {
3400
3982
  if (steps + rejected >= maxSteps || clock() - start > budgetMs) break;
3401
3983
  // Within round-off of the end (a target a hair past a breakpoint just landed on, say):
3402
3984
  // snap to it rather than attempt a step of ~1e-13 s, which can't be resolved and, in a
3403
- // strictly neutral device, fails outright.
3404
- if (tEnd - this.time <= 1e-10 * Math.abs(tEnd)) {
3985
+ // strictly neutral device, fails outright. Round-off of the time and negligible beside the
3986
+ // interval asked for: by the time alone, a GITT pulse of 0.01 s at t = 1e9 s was skipped
3987
+ // whole (the time moved, nothing else did), and from 1e10 s 17% of a pulse train's charge.
3988
+ if (tEnd - this.time <= 1e-10 * Math.abs(tEnd) && tEnd - this.time <= 1e-9 * (tEnd - t0)) {
3405
3989
  this.time = tEnd;
3406
3990
  break;
3407
3991
  }
@@ -3495,7 +4079,10 @@ export class Solver {
3495
4079
  // Stopped short, other than on the caller's wall-time budget: say where, and why.
3496
4080
  let stopped;
3497
4081
  const at = `advance stopped at t = ${this.time.toPrecision(6)} s of ${tEnd.toPrecision(6)}`;
3498
- if (failed) stopped = `${at}: a step didn't converge even when shortened to ${dt.toExponential(1)} s. A drive's jump too big for one step (ramp it), a driven current the device can't keep passing (a diode's reverse recovery past its storage time: stop there), or a device the steady solves also find hard.`;
4082
+ // (What usually does it, most likely first. Round 11's agents read the old list, which led
4083
+ // with a drive's jump, as blaming a modest current pulse: a host emptied at its surface, and
4084
+ // an electrode at open circuit with no double layer, held only by a slow reaction.)
4085
+ if (failed) stopped = `${at}: a step did not converge even when shortened to ${dt.toExponential(1)} s. Look for: a host or surface driven to its limit (emptied or filled at its surface by a current it can no longer pass); an electrode at open circuit held only by a slow reaction, with no double-layer capacitance to keep its level (give its face one: phi capacitive); a drive's jump too big for one step (ramp it); a driven current the device cannot keep passing (a diode's reverse recovery past its storage time: stop there); or a device the steady solves also find hard.`;
3499
4086
  else if (this.time < tEnd && steps + rejected >= maxSteps) stopped = `${at}: maxSteps (${maxSteps}) used up (${rejected} of them rejected). Call again to go on, or loosen tol.`;
3500
4087
  return { converged: !failed, done: this.time >= tEnd, steps, rejected, iterations, trace, ...(stopped ? { stopped } : {}) };
3501
4088
  }
@@ -3583,9 +4170,18 @@ export class Solver {
3583
4170
  if (!(r >= 0 && r < regions.length)) fail(`${path}.region: no region ${JSON.stringify(p.region)}`);
3584
4171
  if (!inside(r)) fail(`${path}.x (${p.x} m) is outside region ${JSON.stringify(p.region)}`);
3585
4172
  } else {
3586
- r = regions.findIndex((_, rr) => inside(rr));
3587
- if (!(Number.isFinite(p.x) && r >= 0)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
4173
+ // At a face, the side where the probe has something to read; both, and it must be told.
4174
+ const has = (rr) => {
4175
+ const mat = this.model.materials[regions[rr].material];
4176
+ return i < 0 ? !mat.conductor && !mat.phiFree : mat.present[i];
4177
+ };
4178
+ const sides = regions.flatMap((_, rr) => (inside(rr) ? [rr] : []));
4179
+ if (!(Number.isFinite(p.x) && sides.length)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
4180
+ const able = sides.filter(has);
4181
+ if (able.length > 1) fail(`${path}.x (${p.x} m) is on the face between ${able.map((rr) => JSON.stringify(regions[rr].name)).join(' and ')}: give region, the side to read`);
4182
+ r = able[0] ?? sides[0];
3588
4183
  }
4184
+ if (i >= 0 && !this.model.materials[regions[r].material].present[i]) fail(`${path}: '${p.species}' is absent from region ${JSON.stringify(regions[r].name)}, so there's nothing to read at x = ${p.x} m`);
3589
4185
  let g = grid.regionStart[r];
3590
4186
  while (g + 1 < grid.regionEnd[r] && x[g + 1] < p.x) g++;
3591
4187
  const w = x[g + 1] > x[g] ? (p.x - x[g]) / (x[g + 1] - x[g]) : 0;
@@ -3704,8 +4300,19 @@ export class Solver {
3704
4300
  this.computeConcentrations();
3705
4301
  }
3706
4302
 
4303
+ // What a floating terminal k passes into the device, which its circuit row holds exactly once
4304
+ // converged: the current it's driven by, or (V_source − V)/R behind a resistance (the source
4305
+ // as the last step saw it, before any jump at its end). Read as a flux through a metal instead,
4306
+ // it carried that flux's round-off, G Δη with G huge: a gate behind R read 1.5e-4 off wherever
4307
+ // Newton stopped an update early, and below ~1e-16 A/m², nothing but noise.
4308
+ passedCurrent(k) {
4309
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.time, !this.atSteady);
4310
+ return d.kind === 'I' ? src : (src - this.termV[k]) / d.R;
4311
+ }
4312
+
3707
4313
  // Terminal current toward +x at the right contact, from the last assembly at this state.
3708
4314
  _terminalCurrent() {
4315
+ if (this.floating.includes(1)) return -this.passedCurrent(1);
3709
4316
  let I = 0;
3710
4317
  for (let i = 0; i < this.n; i++) I += FARADAY * this.z[i] * this.contactFlux.right[i];
3711
4318
  const dt = this.lastDt;
@@ -3739,6 +4346,15 @@ export class Solver {
3739
4346
  if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
3740
4347
  const dT = this.terms[kT].drive;
3741
4348
  if (dT.R > 0) throw new SolverError(`impedance: terminal '${terminal}' has a series resistance; that belongs to the external circuit`);
4349
+ // A small signal returns through the terminals held at a voltage: with every other one driven
4350
+ // by a current (held open), none comes back, and the impedance is infinite (else round-off).
4351
+ const others = this.terms.filter((t, k) => k !== kT);
4352
+ if (others.length && others.every((t) => t.drive.kind === 'I')) {
4353
+ throw new SolverError(
4354
+ `impedance: every other terminal (${others.map((t) => t.name).join(', ')}) is driven by a current, so it is open to a small signal and no current returns through '${terminal}': ` +
4355
+ `its impedance is infinite. Measure at one of those instead (impedance(f, { terminal: '${others[0].name}' }), its voltage against '${terminal}'), or hold one of them at a voltage`,
4356
+ );
4357
+ }
3742
4358
  const N = nB * M, fl = this.floating, K = fl.length;
3743
4359
  this.sourceTime = this.time;
3744
4360
  this.computeConcentrations();
@@ -4290,12 +4906,20 @@ export class Solver {
4290
4906
  if (!Number.isFinite(dt)) return;
4291
4907
  const last = this.model.regions.length - 1;
4292
4908
  const { n, cOld } = this, vol = this.model.grid.vol;
4909
+ // A stretch alone in what a terminal driven by a current feeds takes in what that current
4910
+ // says, which the step kept exactly (see _circuitLaws); the flux through a metal is round-off.
4911
+ const driven = new Map();
4912
+ for (const cs of this.constraints) {
4913
+ if (cs.circuit < 0 || this.terms[cs.circuit].drive.kind !== 'I' || cs.parts.length !== 1 || cs.surface.length || cs.carriers.length) continue;
4914
+ driven.set(cs.parts[0].stretch, (cs.lambda * sourceAt(this.terms[cs.circuit].drive.src, this.sourceTime, this.sourceBefore)) / (cs.parts[0].w * FARADAY));
4915
+ }
4293
4916
  this.stretches.forEach((st, k) => {
4294
4917
  if (!st.connected) return;
4295
4918
  let q = 0;
4296
4919
  if (st.regions[0] === 0) q += this.contactFlux.left[st.species];
4297
4920
  if (st.regions[1] === last) q -= this.contactFlux.right[st.species];
4298
4921
  for (const p of st.ports) q += this.portFlux[p][st.species];
4922
+ if (driven.has(k)) q = driven.get(k);
4299
4923
  let hist = 0;
4300
4924
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) hist += vol[g] * (cOld[g * n + st.species] - cN[g * n + st.species]);
4301
4925
  this.boundaryIntake[k] += q * dt + hist;
@@ -4325,45 +4949,55 @@ export class Solver {
4325
4949
  }
4326
4950
 
4327
4951
  /**
4328
- * Steady state.
4329
- * - If every species stretch is fed by a contact, nothing is conserved on its own, so the
4330
- * true steady equations (dt = ∞, no storage term) are solved directly: no slow modes to
4331
- * wait out, however slow the physics (e.g. exponentially scarce minority carriers).
4332
- * - Otherwise backward-Euler steps at a huge dt, whose storage term pins each conserved
4333
- * amount exactly; dt keeps growing ×10 while the state still moves.
4334
- * If Newton fails, dt ramps up from a small value (pseudo-transient continuation) instead.
4952
+ * Steady state: where a transient from the present state ends, found as the solution of the
4953
+ * steady equations (dt = ∞, no storage term), with no slow modes to wait out, however slow the
4954
+ * physics (e.g. exponentially scarce minority carriers). What the storage term held is held by
4955
+ * the steady system's own rows instead (see _laws): each conserved amount in place of a balance
4956
+ * row that says nothing new, levels that carry nothing held flat, islands' summed balances.
4957
+ * Where Newton from here fails: continuation along the drives, then the pseudo-transient ramp.
4335
4958
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
4336
4959
  */
4337
4960
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
4338
4961
  // a change of drive).
4339
4962
  solveSteady(opts = {}) {
4340
- this.flattening = true;
4341
4963
  this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4964
+ this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
4965
+ this._captureLaws();
4966
+ this.steady = true; // (the steady system's rows; sources at the present time)
4967
+ this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
4342
4968
  try {
4343
4969
  return this._solveSteadyAll(opts);
4344
4970
  } finally {
4345
- this.flattening = false;
4971
+ this.steady = false;
4346
4972
  }
4347
4973
  }
4348
4974
 
4349
4975
  _solveSteadyAll(opts) {
4350
- // A terminal that passes current only by charging (a gate, a capacitance with no species
4351
- // through it) has no steady state under a current drive: it charges for ever, or at I = 0
4352
- // keeps whatever charge it started with, which a steady solve doesn't know.
4353
- const { species } = this.model, ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
4976
+ // A terminal driven by a current that can only store what comes in (a battery's cathode
4977
+ // behind it, closed but for that terminal; a gate, or a capacitance with no species through
4978
+ // it) has no steady state but at no current, where it keeps what it holds.
4979
+ const { species, regions } = this.model;
4354
4980
  for (const k of this.floating) {
4355
- const t = this.terms[k];
4356
- if (t.drive.kind !== 'I') continue;
4357
- const only = t.kind === 'port' ? (p) => p.reactions.length === 0 && !ions(p.species) : (c) => !ions(c.species);
4358
- if (only(t.kind === 'port' ? this.model.ports[t.index] : this.model.contacts[t.side])) {
4359
- throw new DeviceError(`${t.kind === 'port' ? `ports[${t.index}]` : `contacts.${t.side}`} passes current only by charging, so driven by a current it has no steady state; hold it at V (or a source behind R), or advance() in time`);
4360
- }
4981
+ const I = sourceAt(this.terms[k].drive.src, this.time);
4982
+ if (this.terms[k].drive.kind !== 'I' || I === 0) continue;
4983
+ const laws = this.constraints.filter((cs) => cs.terminals.includes(k));
4984
+ if (laws.length === 0 && !this._chargeOnly(k)) continue;
4985
+ const what = laws.flatMap((cs) => cs.parts.map(({ stretch }) => {
4986
+ const st = this.stretches[stretch], [r0, r1] = st.regions;
4987
+ return `${species[st.species].name} in ${regions[r0].name}${r1 > r0 ? `…${regions[r1].name}` : ''}`;
4988
+ }));
4989
+ this.charging = { terminal: this.terms[k].name, current: I, what };
4990
+ return { converged: false, steps: 0, iterations: 0, history: [] };
4361
4991
  }
4362
4992
  const r = this._steadyFromHere(opts);
4363
4993
  this.atSteady = r.converged;
4364
4994
  return r;
4365
4995
  }
4366
4996
 
4997
+ // Steady state from the present one, in three stages: Newton from here; failing that, from a
4998
+ // solved state along the drives (the light ramped up, a current's terminal held at the voltages
4999
+ // that pass it, the voltages ramped from where they were last solved); failing that, the
5000
+ // pseudo-transient ramp, through time.
4367
5001
  _steadyFromHere(opts) {
4368
5002
  this.conditioning = null;
4369
5003
  this.computeConcentrations();
@@ -4373,86 +5007,110 @@ export class Solver {
4373
5007
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
4374
5008
  const held = (d) => d.kind === 'V' && !(d.R > 0);
4375
5009
  const target = sourceAt(dr.src, this.time), level = sourceAt(dl.src, this.time);
4376
- const canContinue = opts.continuation !== false && held(dl) && held(dr) && target !== level;
5010
+ const allowed = opts.continuation !== false;
5011
+ const canContinue = allowed && held(dl) && held(dr) && target !== level;
4377
5012
  const direct = this._directSteady();
4378
5013
  const u0 = Float64Array.from(this.u), u0Lo = Float64Array.from(this.uLo), v0 = Float64Array.from(this.termV);
4379
- // Where a direct solve applies and continuation is possible, don't spend long on the
4380
- // pseudo-transient ramp: one direct attempt first.
4381
- const quick = (canContinue || this.hasGeneration) && direct && opts.continuation !== false;
4382
- let r = this._solveSteady(quick ? { ...opts, maxSteps: 1 } : opts);
4383
- if (r.converged) this.solvedV = [level, target];
4384
- if (r.converged) return r;
4385
- // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
4386
- // nothing, each solve warm from the last.
4387
- let dimmer = false;
4388
- if (this.hasGeneration && opts.continuation !== false) {
5014
+ const restart = () => {
4389
5015
  this.u.set(u0);
4390
5016
  this.uLo.set(u0Lo);
4391
5017
  this.termV.set(v0);
4392
5018
  this.computeConcentrations();
5019
+ };
5020
+ const solved = (q) => {
5021
+ if (q.converged) {
5022
+ this.solvedV = [level, target];
5023
+ this.stepped = false;
5024
+ }
5025
+ return q;
5026
+ };
5027
+ const after = (q, r) => ({ ...q, steps: q.steps + r.steps, iterations: q.iterations + r.iterations });
5028
+
5029
+ // 1. Newton from here. Where a continuation could follow, one direct attempt only, rather
5030
+ // than the pseudo-transient ramp straight away.
5031
+ const quick = (canContinue || this.hasGeneration) && direct && allowed;
5032
+ let r = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
5033
+ if (r.converged) return solved(r);
5034
+ // A transient's state can be a worse start than a cold one: 1 ns after a lit MOS capacitor's
5035
+ // gate jumps, its surface is in deep depletion, minority carriers ten orders below where they
5036
+ // end, and Newton fails from there where it converges from the layout for the drives (and
5037
+ // the stages below took 9000 iterations from there, 2000 from the layout). What a steady
5038
+ // solve keeps it took from the state already, so from here on it goes as a cold device would.
5039
+ if (this.stepped && direct && allowed) {
5040
+ this._refreshSources();
5041
+ this.initFromComposition();
5042
+ u0.set(this.u);
5043
+ u0Lo.set(this.uLo);
5044
+ const q = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
5045
+ if (q.converged) return solved(after(q, r));
5046
+ restart();
5047
+ r = after(q, r);
5048
+ }
5049
+
5050
+ // 2. Continuation. Generation (e.g. light) holding the device far from equilibrium: ramp it up
5051
+ // from nearly nothing, each solve warm from the last.
5052
+ let dimmer = false;
5053
+ if (this.hasGeneration && allowed) {
5054
+ restart();
4393
5055
  this.dimmer = false;
4394
5056
  const g = this._generationContinuation(opts, r);
4395
5057
  dimmer = this.dimmer;
4396
- if (g.converged) {
4397
- this.solvedV = [level, target];
4398
- return g;
4399
- }
5058
+ if (g.converged) return solved(g);
4400
5059
  r = g;
4401
5060
  }
4402
5061
  // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
4403
5062
  // voltage until the current crosses its target, and float it from there.
4404
- if (opts.continuation !== false && this.floating.some((k) => this.terms[k].drive.kind === 'I')) {
4405
- this.u.set(u0);
4406
- this.uLo.set(u0Lo);
4407
- this.termV.set(v0);
4408
- this.computeConcentrations();
5063
+ if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
5064
+ restart();
4409
5065
  const q = this._floatingContinuation(opts, r);
4410
5066
  if (q.converged) return q;
4411
5067
  r = q;
4412
5068
  }
4413
- if (!canContinue) {
4414
- if (quick) {
4415
- // The full pseudo-transient ramp: from the dimmer light's solution where the light's
4416
- // continuation got anywhere (a lit floating base charges from there as it would in
4417
- // time), else from the start.
4418
- if (!dimmer) {
4419
- this.u.set(u0);
4420
- this.uLo.set(u0Lo);
4421
- this.termV.set(v0);
4422
- }
4423
- this.computeConcentrations();
4424
- const full = this._solveSteady(opts);
4425
- return { ...full, steps: full.steps + r.steps, iterations: full.iterations + r.iterations };
4426
- }
4427
- return r;
4428
- }
4429
- // Source continuation: solve with both terminals level (consistent with a cold start), then
4430
- // ramp the right terminal's voltage to its target in adaptive steps.
4431
- const restart = () => {
4432
- this.u.set(u0);
4433
- this.uLo.set(u0Lo);
4434
- this.termV.set(v0);
4435
- this.computeConcentrations();
4436
- };
4437
- restart();
4438
- // Ramp from the contacts' voltages at the last converged solve when the state is that
4439
- // solution (a warm start): the left one first where it moved, the right held where it was,
4440
- // then the right. Otherwise from level terminals.
4441
- const solved = this.solvedV;
4442
- let c;
4443
- if (solved) {
4444
- c = solved[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, solved[0], level, r, true, solved[1]);
4445
- // (Ramping the right from level terminals, solve there first: a sweep's start.)
4446
- if (c.converged) c = this._continuation(opts, 1, solved[1], target, c, c.ramped || solved[1] !== level);
4447
- } else c = this._continuation(opts, 1, level, target, r, false);
4448
- if (c.converged) {
4449
- this.solvedV = [level, target];
4450
- return c;
4451
- }
4452
- restart();
4453
- r = this._solveSteady(opts); // the full pseudo-transient ramp
4454
- if (r.converged) this.solvedV = [level, target];
4455
- return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
5069
+ if (canContinue) {
5070
+ // The right terminal's voltage ramped to its target: from the contacts' voltages at the last
5071
+ // converged solve when the state is that solution (a warm start), the left one first where
5072
+ // it moved, the right held where it was; otherwise from level terminals, consistent with a
5073
+ // cold start.
5074
+ restart();
5075
+ const was = this.solvedV;
5076
+ let c;
5077
+ if (was) {
5078
+ c = was[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, was[0], level, r, true, was[1]);
5079
+ // (Ramping the right from level terminals, solve there first: a sweep's start.)
5080
+ if (c.converged) c = this._continuation(opts, 1, was[1], target, c, c.ramped || was[1] !== level);
5081
+ } else c = this._continuation(opts, 1, level, target, r, false);
5082
+ if (c.converged) return solved(c);
5083
+ // 3. The pseudo-transient ramp, from the start.
5084
+ restart();
5085
+ return solved(after(this._steadySteps(opts), c));
5086
+ }
5087
+ // 3. The pseudo-transient ramp (where the first attempt wasn't it already): from the dimmer
5088
+ // light's solution where the light's continuation got anywhere (a lit floating base charges
5089
+ // from there as it would in time), else from the start.
5090
+ if (!quick) return r;
5091
+ if (!dimmer) restart();
5092
+ else this.computeConcentrations();
5093
+ return after(this._steadySteps(opts), r);
5094
+ }
5095
+
5096
+ // Whether terminal k, driven by a current, feeds a combination that it alone can change, or
5097
+ // only charges a capacitance (so that its steady states are one per amount or charge, not one
5098
+ // per voltage).
5099
+ _bound(k) {
5100
+ return this.constraints.some((cs) => cs.terminals.includes(k)) || this._chargeOnly(k);
5101
+ }
5102
+
5103
+ // Whether terminal k passes current only by charging a capacitance: no charged species through
5104
+ // it (nor, a port, reactions), and a capacitance to charge (a gate's φ law, a port's own).
5105
+ _chargeOnly(k) {
5106
+ const t = this.terms[k], { species } = this.model;
5107
+ const ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
5108
+ if (t.kind === 'port') {
5109
+ const p = this.model.ports[t.index];
5110
+ return p.reactions.length === 0 && !ions(p.species) && !!p.capacitance;
5111
+ }
5112
+ const c = this.model.contacts[t.side];
5113
+ return !ions(c.species) && (c.phi.type === 'capacitive' || c.phi.type === 'pinned');
4456
5114
  }
4457
5115
 
4458
5116
  // A cold steady solve with a terminal driven by a current, which can fail where the same state
@@ -4461,7 +5119,7 @@ export class Solver {
4461
5119
  // the start's until the current crosses its target, the crossing narrowed by bisection, and
4462
5120
  // the terminal floated again from beside it.
4463
5121
  _floatingContinuation(opts, r) {
4464
- const k = this.floating.find((j) => this.terms[j].drive.kind === 'I');
5122
+ const k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j));
4465
5123
  const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
4466
5124
  let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
4467
5125
  const history = (r.history ?? []).slice();
@@ -4524,7 +5182,7 @@ export class Solver {
4524
5182
  this.terms[k].drive = drive;
4525
5183
  this.redrive();
4526
5184
  }
4527
- const q = this._solveSteady(opts);
5185
+ const q = this._steadySteps(opts);
4528
5186
  steps += q.steps ?? 0;
4529
5187
  iterations += q.iterations ?? 0;
4530
5188
  history.push(...(q.history ?? []));
@@ -4567,7 +5225,7 @@ export class Solver {
4567
5225
  try {
4568
5226
  this.generationScale = s;
4569
5227
  // (nearly dark: a cold start can need the full ramp, as a dark device's does)
4570
- let q = this._solveSteady(sub);
5228
+ let q = this._steadySteps(sub);
4571
5229
  steps += q.steps;
4572
5230
  iterations += q.iterations;
4573
5231
  if (!q.converged) return { converged: false, steps, iterations, history };
@@ -4576,7 +5234,7 @@ export class Solver {
4576
5234
  const next = Math.min(1, s * factor);
4577
5235
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4578
5236
  this.generationScale = next;
4579
- q = this._solveSteady(sub);
5237
+ q = this._steadySteps(sub);
4580
5238
  steps += q.steps;
4581
5239
  iterations += q.iterations;
4582
5240
  history.push({ generation: next, converged: q.converged });
@@ -4612,7 +5270,7 @@ export class Solver {
4612
5270
  if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4613
5271
  let q;
4614
5272
  if (!warm) {
4615
- q = this._solveSteady(opts);
5273
+ q = this._steadySteps(opts);
4616
5274
  if (!q.converged) {
4617
5275
  // A cold device's start was laid out for the target (a set() before the first solve
4618
5276
  // builds it there): lay it out again, level.
@@ -4620,7 +5278,7 @@ export class Solver {
4620
5278
  iterations += q.iterations;
4621
5279
  this._refreshSources();
4622
5280
  this.initFromComposition();
4623
- q = this._solveSteady(opts);
5281
+ q = this._steadySteps(opts);
4624
5282
  }
4625
5283
  // (lit, the level start may need the light ramped up too)
4626
5284
  if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
@@ -4632,7 +5290,7 @@ export class Solver {
4632
5290
  const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4633
5291
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4634
5292
  this.sourceOverride.set(k, next);
4635
- q = this._solveSteady(sub);
5293
+ q = this._steadySteps(sub);
4636
5294
  steps += q.steps;
4637
5295
  iterations += q.iterations;
4638
5296
  history.push({ continuation: next, converged: q.converged });
@@ -4656,23 +5314,11 @@ export class Solver {
4656
5314
  }
4657
5315
  }
4658
5316
 
4659
- _solveSteady(opts = {}) {
4660
- this.constrained = true; // spectators' amounts as constraints in the dt = ∞ solves
4661
- this.steady = true;
4662
- try {
4663
- return this._steadySteps(opts);
4664
- } finally {
4665
- this.constrained = false;
4666
- this.steady = false;
4667
- }
4668
- }
4669
-
4670
5317
  _steadySteps({ maxSteps = 80, tol = 1e-11 } = {}) {
4671
5318
  const time = this.time;
4672
5319
  const tau = this.slowestTime();
4673
5320
  const direct = this._directSteady();
4674
5321
  const giant = direct ? Infinity : 1e6 * tau;
4675
- if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
4676
5322
  let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
4677
5323
  let triedDirect = 0;
4678
5324
  let totalIter = 0, steps = 0, converged = false;