driftlet 0.13.0 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/solver.js CHANGED
@@ -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.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}`).join();
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,84 @@ 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
+ for (let w of [...laws.filter(through), ...laws.filter((w) => !through(w))]) {
886
+ for (const [j, k] of this.carriers.entries()) {
887
+ const c = held.get(k);
888
+ if (w[X + j] === 0 || !c) continue;
889
+ const f = w[X + j] / c[X + j], own = w.own;
890
+ w = w.map((x, m) => (Math.abs(x - f * c[m]) < 1e-9 ? 0 : x - f * c[m]));
891
+ w.own = own;
892
+ }
783
893
  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)
894
+ const surface = [...w.keys()].filter((k) => k >= S && k < X && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
895
+ const carriers = this.carriers.flatMap((k, j) => (w[X + j] !== 0 ? [{ k, w: w[X + j] }] : []));
896
+ if (parts.length === 0) continue; // (a surface's own: node by node, below)
786
897
  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.
898
+ if (parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
899
+ const v = new Float64Array(S + T);
900
+ for (const { stretch: k, w: wk } of parts) {
901
+ const st = this.stretches[k];
902
+ if (this._moves(st) && !spectators.has(rowOf(k))) v[k] = wk;
903
+ if (this.z[st.species] !== 0) for (const t of driven(st)) v[S + t] = wk / this.z[st.species];
904
+ }
905
+ for (const { k, w: wk } of carriers) v[S + k] = wk / zc(k);
906
+ for (const [col, row] of chosen) {
907
+ const f = v[col];
908
+ if (f !== 0) for (let j = 0; j < S + T; j++) v[j] -= f * row[j];
909
+ }
910
+ const scale = v.reduce((m, x) => Math.max(m, Math.abs(x)), 0);
911
+ if (!(scale > 1e-9)) continue;
912
+ const ok = (j) => j !== undefined && Math.abs(v[j]) > 1e-9 * scale;
913
+ 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);
914
+ 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)
915
+ chosen.push([col, v.map((x) => x / v[col])]);
916
+ if (col >= S) held.set(col - S, w);
917
+ add(parts, col < S ? col : parts[0].stretch, surface, col < S ? -1 : col - S, carriers);
918
+ }
919
+ // A combination that nothing carries anywhere is conserved node by node: of still stretches
920
+ // (trap states: X⁰ + X⁻ under e⁻ + X⁰ = X⁻), or of an electrode's surface species (adsorbates
921
+ // that only turn into each other). At each node its weighted sum stays what it was, a row local
922
+ // to that node's block, in place of one of their balances.
794
923
  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];
924
+ const span = ([g0, g1]) => Array.from({ length: g1 - g0 + 1 }, (_, j) => g0 + j);
925
+ for (const w of laws) {
926
+ const parts = [...w.keys()].filter((k) => w[k] !== 0);
927
+ if (parts.some((k) => k >= X)) continue; // (through an electrode: above)
928
+ 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));
929
+ if (sets.some((set) => set === null || set.length !== sets[0].length || set.some((g, j) => g !== sets[0][j]))) continue; // (side by side only)
930
+ for (const k of parts) if (k < S) this.stretches[k].conserved = true;
931
+ const slot = (k) => (k < S ? 1 + this.stretches[k].species : 1 + this.n + this.surfCols[k - S][1]);
806
932
  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),
933
+ parts: parts.map((k) => (k < S ? { i: this.stretches[k].species, w: w[k] } : { q: this.surfCols[k - S][1], w: w[k] })),
934
+ row: slot(parts.includes(w.own) ? w.own : parts[0]),
935
+ nodes: sets[0],
936
+ reference: new Float64Array(sets[0].length),
811
937
  });
812
938
  }
813
939
  // Islands: the pieces of a stretch between its faces. One that nothing else feeds (no
@@ -821,7 +947,7 @@ export class Solver {
821
947
  this.pinnedIslands = [];
822
948
  this.islandsOn = false; // (newton() turns them on, see there)
823
949
  this.pins = [];
824
- const used = new Set(this.constraints.map((cs) => cs.row));
950
+ const used = new Set(this.constraints.map((cs) => cs.row).filter((o) => o >= 0));
825
951
  for (const st of this.stretches) {
826
952
  const i = st.species, [s0, s1] = st.regions;
827
953
  if (!st.mobile || conductor(st)) continue;
@@ -870,19 +996,68 @@ export class Solver {
870
996
  }
871
997
  }
872
998
  for (const isl of this.islands) isl.outside = isl.outside.filter((o) => !used.has(o));
873
- this.constrained = false;
999
+
1000
+ // Time steps only: a terminal behind a resistance that alone feeds a combination reads its
1001
+ // current by that combination's change too (see _circuitLaws), its combinations found with it
1002
+ // counted as driven by a current. (Not in the steady system, where it holds its level.)
1003
+ this.timeLaws = [];
1004
+ const behind = this.floating.filter((k) => this.terms[k].drive.kind !== 'I');
1005
+ if (behind.length > 0) {
1006
+ const steadyCarriers = this.carriers, more = this._conservedMoieties(true, behind), X2 = S + this.surfCols.length;
1007
+ for (const w of more) {
1008
+ const parts = [...w.keys()].filter((k) => k < S && w[k] !== 0).map((k) => ({ stretch: k, w: w[k] }));
1009
+ if (parts.length === 0 || parts.some((p) => !this._moves(this.stretches[p.stretch]) && !this._still(this.stretches[p.stretch]))) continue;
1010
+ const surface = [...w.keys()].filter((k) => k >= S && k < X2 && w[k] !== 0).map((k) => ({ col: k - S, w: w[k] }));
1011
+ const carriers = this.carriers.flatMap((k, j) => (w[X2 + j] !== 0 ? [{ k, w: w[X2 + j] }] : []));
1012
+ // Fed by one terminal behind a resistance and nothing else that passes current.
1013
+ const fedBy = new Set([...carriers.map((p) => p.k), ...parts.flatMap((p) => this.floating.filter((k) => this._feeds(k, this.stretches[p.stretch])))]);
1014
+ if (fedBy.size !== 1) continue;
1015
+ const [k] = fedBy;
1016
+ if (!behind.includes(k) || this.timeLaws.some((cs) => cs.circuit === k)) continue;
1017
+ const zc = k >= 2 ? this.z[model.ports[k - 2].terminal] : 0;
1018
+ 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);
1019
+ if (!lambda) continue;
1020
+ let nNodes = parts.reduce((m, p) => m + this.stretches[p.stretch].nodes[1] - this.stretches[p.stretch].nodes[0] + 1, 0);
1021
+ for (const p of surface) nNodes += model.ports[this.surfCols[p.col][0]].nodes.length * model.ports[this.surfCols[p.col][0]].surface.length;
1022
+ for (const p of carriers) nNodes += model.ports[p.k - 2].nodes.length;
1023
+ 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 });
1024
+ }
1025
+ this.carriers = steadyCarriers;
1026
+ }
1027
+ }
1028
+
1029
+ // What each combination fed by a terminal driven at no current holds now, which a steady solve
1030
+ // keeps: a closed battery at open circuit stays at the charge it had.
1031
+ _captureLaws() {
1032
+ // A terminal at no current that passes current only by charging a capacitance keeps its charge.
1033
+ this.chargeRefs.clear();
1034
+ const charged = this.floating.filter((k) => this.terms[k].drive.kind === 'I' && this._chargeOnly(k));
1035
+ if (charged.length > 0) {
1036
+ this._assembleBookkeeping(Infinity);
1037
+ for (const k of charged) this.chargeRefs.set(k, k >= 2 ? this.portQ[k - 2] : k === 0 ? this.contactD.left : -this.contactD.right);
1038
+ }
1039
+ this.lawRefs.clear();
1040
+ if (this.constraints.some((cs) => cs.carriers.some(({ k }) => this.model.ports[k - 2].capacitance)) && charged.length === 0) this._assembleBookkeeping(Infinity);
1041
+ for (const cs of this.constraints) {
1042
+ if (cs.terminals.length === 0) continue;
1043
+ let a = 0;
1044
+ for (const { stretch, w } of cs.parts) a += w * this.amount(this.stretches[stretch]);
1045
+ for (const { col, w } of cs.surface) a += w * this.surfaceAmount(col);
1046
+ 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);
1047
+ this.lawRefs.set(cs.key, a);
1048
+ }
874
1049
  }
875
1050
 
876
1051
  // Each local constraint's weighted sum at each node, as the state holds it now: what a steady
877
1052
  // solve keeps.
878
1053
  _captureLocal() {
879
- const { n, c } = this;
1054
+ const { n, c, th, nSurf } = this;
880
1055
  for (const lc of this.localConstraints) {
881
- for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
1056
+ lc.nodes.forEach((g, j) => {
882
1057
  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
- }
1058
+ for (const { i, q, w } of lc.parts) t += w * (i !== undefined ? c[g * n + i] : th[g * nSurf + q]);
1059
+ lc.reference[j] = t;
1060
+ });
886
1061
  }
887
1062
  }
888
1063
 
@@ -892,7 +1067,7 @@ export class Solver {
892
1067
  _applyIslands() {
893
1068
  const { u, res } = this, R = this.rix, out = [];
894
1069
  for (const isl of this.islands) {
895
- if (this.flattening && this.flatStretches.includes(isl.stretch)) continue;
1070
+ if (this.flatStretches.includes(isl.stretch)) continue;
896
1071
  const b0 = Math.floor(isl.row / this.M), r0 = isl.row % this.M;
897
1072
  if (this.loc[isl.row] < 0) continue;
898
1073
  // Gathered by column (the faces' u, then each outside row's entries, which cancel those u
@@ -953,28 +1128,47 @@ export class Solver {
953
1128
 
954
1129
  // The local constraints' rows (steady solves only): Σ w c_i − reference at each node.
955
1130
  _applyLocalConstraints() {
956
- const { n, M, c, res, dA: d } = this;
1131
+ const { n, M, c, th, nSurf, res, dA: d } = this;
957
1132
  for (const lc of this.localConstraints) {
958
- for (let g = lc.nodes[0]; g <= lc.nodes[1]; g++) {
1133
+ lc.nodes.forEach((g, j) => {
959
1134
  const b = this.blockOfNode[g];
960
- if (this.loc[b * M + lc.row] < 0) continue;
1135
+ if (this.loc[b * M + lc.row] < 0) return;
961
1136
  this._replaceRow(b, lc.row);
962
1137
  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]);
1138
+ for (const { i, q, w } of lc.parts) {
1139
+ if (i !== undefined) {
1140
+ t += w * c[g * n + i];
1141
+ this._dc(g, i, d);
1142
+ for (let s = 0; s < M; s++) if (d[s] !== 0) this._j(b, lc.row, b, s, w * d[s]);
1143
+ continue;
1144
+ }
1145
+ // A coverage, with ∂θ_q/∂η_x = θ_q (δ_qx − θ_x) over its surface's species.
1146
+ const tq = th[g * nSurf + q], ns = this.model.ports[this.surfPort[g]].surface.length;
1147
+ t += w * tq;
1148
+ 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
1149
  }
968
- res[this.rix[b * M + lc.row]] = t - lc.reference[g - lc.nodes[0]];
969
- }
1150
+ res[this.rix[b * M + lc.row]] = t - lc.reference[j];
1151
+ });
970
1152
  }
971
1153
  }
972
1154
 
973
1155
  // 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;
1156
+ // stoichiometry (a row per reaction, over the stretches it touches) together with what the
1157
+ // terminals feed. A terminal held at a voltage (or behind a resistance, which holds it at
1158
+ // steady state) feeds each stretch it links freely: a unit row. With `drives`, one driven by a
1159
+ // current feeds its charged species only as that current allows, in proportion to their
1160
+ // charges, so a combination weighted by z there is conserved while it passes none: a battery's
1161
+ // cathode, its electrons behind an open circuit, keeps its charge. Exact integer data, so plain
1162
+ // elimination will do.
1163
+ _conservedMoieties(drives = false, alsoCurrent = []) {
1164
+ const { model, n, z } = this, S = this.stretches.length;
1165
+ // With `drives`, an electrode port driven by a current whose reactions take its carrier from
1166
+ // the electrode has a column of its own, last: what the electrode holds, its charge Q/(zF),
1167
+ // which the current feeds and the reactions draw on (a port at no current with M⁺ + e⁻ = M(s)
1168
+ // keeps M⁺ + Q/F, or with no capacitance, Q = 0, the M⁺ alone).
1169
+ const byCurrent = (t, k) => t.drive.kind === 'I' || alsoCurrent.includes(k); // (with `alsoCurrent`, terminals counted as such)
1170
+ this.carriers = drives ? this.terms.flatMap((t, k) => (byCurrent(t, k) && k >= 2 && model.ports[k - 2].reactions.length > 0 ? [k] : [])) : [];
1171
+ const X = S + this.surfCols.length, C = X + this.carriers.length;
978
1172
  const rows = [];
979
1173
  model.interfaces.forEach((itf, f) => {
980
1174
  for (const rx of itf.reactions) {
@@ -992,6 +1186,7 @@ export class Solver {
992
1186
  for (const p of rx.part) {
993
1187
  if (p.side === 0) row[this.stretchOf[port.region * n + p.i]] += p.nu;
994
1188
  else if (p.side === 2) row[S + this.surfColOf[k] + p.s] += p.nu;
1189
+ else if (this.carriers.includes(k + 2)) row[X + this.carriers.indexOf(k + 2)] += p.nu;
995
1190
  }
996
1191
  rows.push(row);
997
1192
  }
@@ -1005,16 +1200,32 @@ export class Solver {
1005
1200
  rows.push(row);
1006
1201
  }
1007
1202
  });
1008
- this.stretches.forEach((st, k) => {
1009
- if (!st.contactFed) return;
1203
+ const unit = (k) => {
1010
1204
  const row = new Float64Array(C);
1011
1205
  row[k] = 1;
1012
1206
  rows.push(row);
1207
+ };
1208
+ this.terms.forEach((t, k) => {
1209
+ const fed = this.stretches.flatMap((st, j) => (this._feeds(k, st) ? [j] : []));
1210
+ if (!(drives && byCurrent(t, k))) return fed.forEach(unit);
1211
+ const charged = fed.filter((j) => z[this.stretches[j].species] !== 0).map((j) => [j, z[this.stretches[j].species]]);
1212
+ if (this.carriers.includes(k)) charged.push([X + this.carriers.indexOf(k), z[model.ports[k - 2].terminal]]);
1213
+ fed.filter((j) => z[this.stretches[j].species] === 0).forEach(unit);
1214
+ for (let a = 1; a < charged.length; a++) {
1215
+ const row = new Float64Array(C);
1216
+ row[charged[0][0]] = 1 / charged[0][1];
1217
+ row[charged[a][0]] = -1 / charged[a][1];
1218
+ rows.push(row);
1219
+ }
1013
1220
  });
1014
- // Reduced row echelon form; the free columns give the null space.
1221
+ // Reduced row echelon form; the free columns give the null space. Immobile stretches' columns
1222
+ // go first, so that a combination of them alone (trap states, X⁰ + X⁻) comes out on its own,
1223
+ // to be kept node by node, apart from any that moves (e⁻ − X⁰, the electrons the traps hold).
1224
+ const order = [...Array(C).keys()].sort((a, b) => (b < S && this._still(this.stretches[b])) - (a < S && this._still(this.stretches[a])));
1015
1225
  const pivots = [];
1016
1226
  let r = 0;
1017
- for (let col = 0; col < C && r < rows.length; col++) {
1227
+ for (const col of order) {
1228
+ if (r >= rows.length) break;
1018
1229
  let best = r;
1019
1230
  for (let i = r + 1; i < rows.length; i++) if (Math.abs(rows[i][col]) > Math.abs(rows[best][col])) best = i;
1020
1231
  if (Math.abs(rows[best][col]) < 1e-9) continue;
@@ -1040,6 +1251,7 @@ export class Solver {
1040
1251
  const v = -rows[i][free];
1041
1252
  w[col] = Math.abs(v) < 1e-9 ? 0 : v;
1042
1253
  });
1254
+ w.own = free; // (its weight is 1 there, and no other vector's is: the row it replaces)
1043
1255
  basis.push(w);
1044
1256
  }
1045
1257
  return basis;
@@ -1056,39 +1268,19 @@ export class Solver {
1056
1268
  // sum of the others. That row is dense, so it's kept aside: the factorised matrix gets a pin
1057
1269
  // (identity row) there instead, and _solveBordered restores the constraint.
1058
1270
  _applyConstraints() {
1059
- const { n, M, res, c, dA: d } = this;
1060
- const vol = this.model.grid.vol;
1271
+ const { M, res } = this;
1061
1272
  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
- });
1273
+ const { amount, reference, dV } = this._lawAmount(cs);
1274
+ cs.res = amount - (cs.terminals.length > 0 ? (this.lawRefs.get(cs.key) ?? amount) : reference);
1275
+ if (cs.circuit >= 0) {
1276
+ // In place of the terminal's circuit row (its I = 0).
1277
+ const C = this.termC[cs.circuit];
1278
+ C.fill(0);
1279
+ for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += cs.w[j];
1280
+ this.termRes[cs.circuit] = cs.res;
1281
+ this.termDI[cs.circuit] = dV;
1282
+ continue;
1089
1283
  }
1090
- cs.len = len;
1091
- cs.res = amount - reference;
1092
1284
  const b0 = Math.floor(cs.row / M), r0 = cs.row % M;
1093
1285
  this._replaceRow(b0, r0);
1094
1286
  this._j(b0, r0, b0, r0, 1);
@@ -1096,6 +1288,122 @@ export class Solver {
1096
1288
  }
1097
1289
  }
1098
1290
 
1291
+ // A conserved combination's amount as the state holds it (and its reference, closed), with its
1292
+ // derivatives in cs.idx/w (and dV, by the voltage of a terminal whose electrode's charge it
1293
+ // counts).
1294
+ _lawAmount(cs) {
1295
+ const { n, M, c, dA: d } = this;
1296
+ const vol = this.model.grid.vol;
1297
+ let amount = 0, reference = 0, len = 0, gross = 0;
1298
+ for (const { stretch, w } of cs.parts) {
1299
+ const st = this.stretches[stretch], i = st.species;
1300
+ if (cs.terminals.length === 0) reference += w * this.referenceAmounts[stretch];
1301
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
1302
+ amount += w * vol[g] * c[g * n + i];
1303
+ gross += Math.abs(w * vol[g] * c[g * n + i]);
1304
+ if (this.nodeConductor[g] >= 0) {
1305
+ // A metal's carriers: the sheet at a charged face, vol·c = ±D_f·A/(zF), held by the
1306
+ // face's displacement.
1307
+ const f = this.sheetFace[g];
1308
+ if (f >= 0 && this.loc[this.blockOfFace[f] * M] >= 0) {
1309
+ cs.idx[len] = this.rix[this.blockOfFace[f] * M];
1310
+ cs.w[len++] = (w * this.sheetSign[g] * this.model.grid.area[g]) / (this.z[i] * FARADAY);
1311
+ }
1312
+ continue;
1313
+ }
1314
+ this._dc(g, i, d);
1315
+ const b = this.blockOfNode[g];
1316
+ for (let r = 0; r < M; r++) {
1317
+ if (d[r] === 0 || this.loc[b * M + r] < 0) continue;
1318
+ cs.idx[len] = this.rix[b * M + r];
1319
+ cs.w[len++] = w * vol[g] * d[r];
1320
+ }
1321
+ }
1322
+ }
1323
+ // What the electrodes' surfaces hold of it: Γ Σ v·a·θ, with ∂θ_s/∂η_x = θ_s (δ_sx − θ_x).
1324
+ for (const { col, w } of cs.surface) {
1325
+ const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity, ns = port.surface.length;
1326
+ if (cs.terminals.length === 0) reference += w * this.surfaceRef[col];
1327
+ port.nodes.forEach((g, j) => {
1328
+ const f = w * G * vol[g] * this.portArea[k][j], b = this.blockOfNode[g], tq = this.th[g * this.nSurf + q];
1329
+ amount += f * tq;
1330
+ gross += Math.abs(f * tq);
1331
+ for (let x = 0; x < ns; x++) {
1332
+ cs.idx[len] = this.rix[b * M + 1 + n + x];
1333
+ cs.w[len++] = f * tq * ((q === x ? 1 : 0) - this.th[g * this.nSurf + x]);
1334
+ }
1335
+ });
1336
+ }
1337
+ // What an electrode holds, its charge Q/(zF) on the port's capacitance (none without one),
1338
+ // Q = Σ vol·a·C (V − zeroCharge − φ) over its nodes (as _portCapacitance has it).
1339
+ let dV = 0;
1340
+ for (const { k, w } of cs.carriers) {
1341
+ const port = this.model.ports[k - 2];
1342
+ if (!port.capacitance) continue;
1343
+ const f = w / (this.z[port.terminal] * FARADAY), { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k];
1344
+ const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
1345
+ port.nodes.forEach((g, j) => {
1346
+ if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
1347
+ const b = this.blockOfNode[g], s = vol[g] * this.portArea[k - 2][j] * Cs;
1348
+ amount += f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M]));
1349
+ gross += Math.abs(f * s * (V - zeroCharge - this.VT * (this.u[b * M] + this.uLo[b * M])));
1350
+ dV += f * s;
1351
+ cs.idx[len] = this.rix[b * M];
1352
+ cs.w[len++] = -f * s * this.VT;
1353
+ });
1354
+ }
1355
+ cs.len = len;
1356
+ return { amount, reference, dV, gross };
1357
+ }
1358
+
1359
+ // The same amount at the step's start, as the storage terms have it (BDF2's c* included).
1360
+ _lawStart(cs) {
1361
+ const { n, nSurf } = this, vol = this.model.grid.vol;
1362
+ let a = 0;
1363
+ for (const { stretch, w } of cs.parts) {
1364
+ const st = this.stretches[stretch], i = st.species;
1365
+ for (let g = st.nodes[0]; g <= st.nodes[1]; g++) a += w * vol[g] * this.cOld[g * n + i];
1366
+ }
1367
+ for (const { col, w } of cs.surface) {
1368
+ const [k, q] = this.surfCols[col], port = this.model.ports[k], G = port.surface[0].capacity;
1369
+ port.nodes.forEach((g, j) => (a += w * G * vol[g] * this.portArea[k][j] * this.thOld[g * nSurf + q]));
1370
+ }
1371
+ 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);
1372
+ return a;
1373
+ }
1374
+
1375
+ // Time steps: a terminal driven by a current that alone feeds a conserved combination can read
1376
+ // its current as the combination's change, F (S − S*)/(λ dt), which the step's balance rows sum
1377
+ // to exactly, rather than as the flux through it. Both are exact; they differ in round-off and
1378
+ // in what Newton can see. Through a metal, the flux is G Δη, its two η all but equal: a floating
1379
+ // gate's charging current fell below that at steps of ~0.03 s, and the step came out singular.
1380
+ // Over long steps the change is the better kept (a closed host at open circuit, read by its
1381
+ // flux, drifted from its charge by 3 mV over 5e11 s, and its steps failed by 4e12 s). Over
1382
+ // short ones it's the amount's round-off over dt (a battery's cathode holds 0.04 mol/m² of
1383
+ // electrons: 1e-6 A/m² at steps of 1e-6 s, enough to reject steps at tol 1e-8), where the flux,
1384
+ // its levels carried in two words (u + uLo), is far cleaner than ε G. So a step reads the change
1385
+ // where that's ε·F·S/(λ dt) below 1e-6 of the flux's ε G, as its start has them, keeps to it
1386
+ // throughout, and where a step read by the flux fails, it's tried once more read by the change
1387
+ // (see step).
1388
+ _circuitLaws(dt) {
1389
+ for (const cs of [...this.constraints, ...this.timeLaws]) {
1390
+ const k = cs.circuit;
1391
+ if (k < 0 || !this.floating.includes(k)) continue;
1392
+ const { amount, dV, gross } = this._lawAmount(cs), f = FARADAY / (cs.lambda * dt), C = this.termC[k];
1393
+ if (!this.lawForm.has(cs)) {
1394
+ let G = 0;
1395
+ for (let j = 0; j < C.length; j++) G = Math.max(G, Math.abs(C[j]));
1396
+ this.lawForm.set(cs, Math.abs(f) * gross < 1e-6 * G);
1397
+ }
1398
+ if (!this.lawForm.get(cs)) continue;
1399
+ C.fill(0);
1400
+ for (let j = 0; j < cs.len; j++) C[cs.idx[j]] += f * cs.w[j];
1401
+ const d = this.terms[k].drive, src = sourceAt(d.src, this.sourceTime, this.sourceBefore);
1402
+ this.termRes[k] = f * (amount - this._lawStart(cs)) - (d.kind === 'I' ? src : (src - this.termV[k]) / d.R);
1403
+ this.termDI[k] = f * dV + (d.kind === 'I' ? 0 : 1 / d.R);
1404
+ }
1405
+ }
1406
+
1099
1407
  // Solve the full Newton system, J δ = rhs, with the extra unknowns and rows that don't fit the
1100
1408
  // block-tridiagonal matrix T: the floating terminals' voltages (with their circuit rows), and
1101
1409
  // the rows pinned in T: the spectators' conserved amounts (see _applyConstraints) and the
@@ -1114,10 +1422,11 @@ export class Solver {
1114
1422
  for (let a = 0; a < S; a++) {
1115
1423
  if (!cols[a]) cols[a] = new Float64Array(N + 1);
1116
1424
  if (a < P) {
1117
- const row = this.rix[pins[a].row];
1118
- e[row] = 1;
1425
+ // The response to a unit pin, or where the pin moves a flat stretch, to its shift.
1426
+ const col = pins[a].col ?? [pins[a].row];
1427
+ for (const o of col) e[this.rix[o]] = 1;
1119
1428
  this._solveLinear(e, cols[a]);
1120
- e[row] = 0;
1429
+ for (const o of col) e[this.rix[o]] = 0;
1121
1430
  } else this._solveLinear(this.termB[fl[a - P]], cols[a]);
1122
1431
  }
1123
1432
  // Row a of the small system, as a dot product with a compact vector (sparse for pins).
@@ -1194,9 +1503,13 @@ export class Solver {
1194
1503
  // change of source is a discontinuity, so time stepping restarts its order.
1195
1504
  redrive() {
1196
1505
  this.atSteady = false;
1197
- const before = this.floating;
1506
+ const before = this.floating, driven = this.drivenKey;
1198
1507
  this.floating = this.terms.flatMap((t, k) => (t.drive.kind === 'I' || t.drive.R > 0 ? [k] : []));
1199
1508
  if (this.floating.length !== before.length) this.deltaV = null;
1509
+ // A terminal newly driven by a current (or no longer) can close a combination behind it.
1510
+ // (and one put behind a resistance, or taken from it, its time steps' reading: see _laws)
1511
+ this.drivenKey = this.terms.map((t) => `${t.drive.kind}${t.drive.R > 0 ? 'R' : ''}`).join();
1512
+ if (this.drivenKey !== driven) this._laws();
1200
1513
  this._refreshSources();
1201
1514
  this.history = [];
1202
1515
  }
@@ -1355,11 +1668,15 @@ export class Solver {
1355
1668
  if (!mat.phiFree && !responds && mat.ideal && electrode) {
1356
1669
  // Nor here, but an electrode spread through it does: start with the port's first reaction
1357
1670
  // that can be balanced at equilibrium with the port's level (the electrode at open circuit).
1671
+ const bare = 1 - electrode.surface.reduce((t, sp) => t + sp.theta0, 0);
1358
1672
  for (const rx of electrode.reactions) {
1359
1673
  let a = rx.fixedA, s = 0;
1360
1674
  for (const p of rx.part) {
1361
1675
  if (p.side === 1) a -= p.nu * this.portEta(electrode, p.i);
1362
- else if (mode[p.i] === 2) {
1676
+ else if (p.side === 2) {
1677
+ const sp = electrode.surface[p.s]; // (at its starting coverage)
1678
+ a -= p.nu * (sp.mu0 / model.RT + Math.log(sp.theta0 / bare));
1679
+ } else if (mode[p.i] === 2) {
1363
1680
  a -= p.nu * (Math.log(cFix[p.i] / mat.cRef[p.i]) + mat.mu0[p.i] / model.RT);
1364
1681
  s += p.nu * z[p.i];
1365
1682
  } else s = NaN;
@@ -1404,6 +1721,31 @@ export class Solver {
1404
1721
  }
1405
1722
  }
1406
1723
  }
1724
+ // An insertion host's φ is a gauge: c0 sets only its neutral combination's level, not how that
1725
+ // splits between its ion and its electrons. Start its ion level with the same ion's beside it
1726
+ // across a face (the equilibrium a link or a transfer reaction heads for), so its electrons,
1727
+ // and a floating contact on them, start where the cell's open-circuit voltage puts them, not
1728
+ // volts away. (A shift of φ̂ and each η by z·Δ: the concentrations don't change.)
1729
+ regions.forEach((reg, r) => {
1730
+ const mat = materials[reg.material];
1731
+ if (!mat.phiFree) return;
1732
+ 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]];
1733
+ for (const [nr, gIn, gOut] of sides) {
1734
+ if (gOut < 0) continue;
1735
+ const other = materials[regions[nr].material];
1736
+ if (other.phiFree || other.conductor) continue;
1737
+ const i = z.findIndex((zi, j) => zi !== 0 && mat.present[j] && other.present[j]);
1738
+ if (i < 0) continue;
1739
+ const d = (u[this.blockOfNode[gOut] * M + 1 + i] - u[this.blockOfNode[gIn] * M + 1 + i]) / z[i];
1740
+ if (!Number.isFinite(d)) continue;
1741
+ for (let g = grid.regionStart[r]; g <= grid.regionEnd[r]; g++) {
1742
+ const b = this.blockOfNode[g];
1743
+ u[b * M] += d;
1744
+ for (let j = 0; j < n; j++) if (mat.present[j]) u[b * M + 1 + j] += z[j] * d;
1745
+ }
1746
+ return;
1747
+ }
1748
+ });
1407
1749
  // A floating contact facing a held one starts where the start's own composition puts it: the
1408
1750
  // held voltage plus the difference between the two terminal species' levels beside each
1409
1751
  // contact (c0 sets them, and φ was chosen for neutrality, not to match either contact), so
@@ -1652,6 +1994,7 @@ export class Solver {
1652
1994
  res.fill(0);
1653
1995
  this.dtNow = dt;
1654
1996
  if (this.lin) this.lin.reset(1 / dt);
1997
+ if (this.steady && dt === Infinity) this._snapFlat();
1655
1998
  this.computeConcentrations();
1656
1999
 
1657
2000
  // Regions, each assembled by the kernel for its kind: a conductor (its carrier only), a
@@ -1683,13 +2026,15 @@ export class Solver {
1683
2026
  // Terminals: ports (after every other term at their nodes, so a held level can read its
1684
2027
  // flux), then contacts, then the circuit rows of the floating ones.
1685
2028
  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] : [];
2029
+ if (Number.isFinite(dt) && (this.constraints.length > 0 || this.timeLaws.length > 0)) this._circuitLaws(dt);
2030
+ // The steady system's own rows (see _laws): levels held flat, conserved amounts, islands'
2031
+ // balances, the last two kept aside for _solveBordered.
2032
+ const steady = this.steady && dt === Infinity;
2033
+ if (steady) this._flatRows();
2034
+ if (steady && this.constraints.length > 0) this._applyConstraints();
2035
+ if (steady && this.localConstraints.length > 0) this._applyLocalConstraints();
2036
+ this.pinnedIslands = steady && this.islandsOn ? this._applyIslands() : [];
2037
+ this.pins = steady ? [...this.constraints.filter((cs) => cs.circuit < 0), ...this.pinnedIslands] : [];
1693
2038
  this.transformed = this.combining && dt !== Infinity;
1694
2039
  if (this.transformed) this._chargeRows(dt);
1695
2040
  }
@@ -2347,6 +2692,21 @@ export class Solver {
2347
2692
  return P * -Math.expm1(-(aHi + aLo));
2348
2693
  }
2349
2694
 
2695
+ // A bulk reaction's forward one-way rate at node g (mol/(m³·s)); its backward one is that
2696
+ // times e^{−a}, so their difference is bulkRate's.
2697
+ bulkForward(rx, g) {
2698
+ const { n, M, u, uLo, c } = this, b = this.blockOfNode[g];
2699
+ const law = rx.srh?.[this.nodeMaterial[g]];
2700
+ if (law) {
2701
+ let a = rx.fixedA;
2702
+ for (let p = 0; p < rx.sp.length; p++) a += rx.nu[p] * (u[b * M + 1 + rx.sp[p]] + uLo[b * M + 1 + rx.sp[p]]);
2703
+ return this._srhRate(law, c[g * n + law.n.i], c[g * n + law.p.i], a).forward;
2704
+ }
2705
+ let P = rx.generation ? rx.kfNode[g] * this.generationScale : rx.kfNode[g];
2706
+ for (let p = 0; p < rx.sp.length; p++) if (rx.nu[p] > 0) P *= rx.nu[p] === 1 ? c[g * n + rx.sp[p]] : powi(c[g * n + rx.sp[p]], rx.nu[p]);
2707
+ return P;
2708
+ }
2709
+
2350
2710
  // The larger of a bulk reaction's two one-way rates at node g (mol/(m³·s)): the scale its net
2351
2711
  // rate is read against (in equilibrium the net rate is their round-off).
2352
2712
  bulkOneWay(rx, g) {
@@ -2376,7 +2736,7 @@ export class Solver {
2376
2736
  const dDn = tp * cn + tp * n1 * kN + tn * p1 * (1 - kN);
2377
2737
  const dDp = tp * n1 * kP + tn * cp + tn * p1 * (1 - kP);
2378
2738
  const dDa = tp * n1 * kA + tn * p1 * (-1 - kA);
2379
- return { rate, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
2739
+ return { rate, forward: np / den, oneWay: (np / den) * Math.max(1, Math.exp(-a)), Cn: rate * (1 - dDn / den), Cp: rate * (1 - dDp / den), Ca: (np / den) * (1 - f) - (rate * dDa) / den };
2380
2740
  }
2381
2741
 
2382
2742
  // A face reaction (see normalizeFaceReactions). Its rate r_k is an unknown of the face block,
@@ -2521,7 +2881,8 @@ export class Solver {
2521
2881
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
2522
2882
  const deta = target - (u[o] + uLo[o]); // (μ̄_out − μ̄)/RT
2523
2883
  if (link.type === 'equilibrium') {
2524
- if (g === 0 || g === this.nNodes - 1) continue; // a device end node's level is its contact's business
2884
+ // a device end node's level is its contact's business, where the contact links it
2885
+ if ((g === 0 && this.model.contacts.left.species[i].type !== 'blocked') || (g === this.nNodes - 1 && this.model.contacts.right.species[i].type !== 'blocked')) continue;
2525
2886
  flux[i] += res[R[o]];
2526
2887
  this._captureRow(b, r, zF, C);
2527
2888
  this._replaceRow(b, r);
@@ -2663,6 +3024,9 @@ export class Solver {
2663
3024
  const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2664
3025
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2665
3026
  const sets = (side) => ['pinned', 'bulk'].includes(this.model.contacts[side].phi.type);
3027
+ // Its current, the charging (Q − Q at the step's start)/dt; or, steady at no current, the
3028
+ // charge it keeps in place of that (see _captureLaws).
3029
+ const rate = Number.isFinite(dt) ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
2666
3030
  let Q = 0, dQdV = 0;
2667
3031
  port.nodes.forEach((g, w) => {
2668
3032
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
@@ -2675,12 +3039,12 @@ export class Solver {
2675
3039
  res[R[b * M]] -= q;
2676
3040
  this._j(b, 0, b, 0, s * VT);
2677
3041
  B[R[b * M]] -= s;
2678
- if (Number.isFinite(dt)) Ct[R[b * M]] += (-s * VT) / dt;
3042
+ if (rate > 0) Ct[R[b * M]] += -s * VT * rate;
2679
3043
  });
2680
3044
  this.portQ[k - 2] = Q;
2681
- if (Number.isFinite(dt)) {
2682
- this.termI[k] += (Q - this.portQStart[k - 2]) / dt;
2683
- this.termDI[k] += dQdV / dt;
3045
+ if (rate > 0) {
3046
+ this.termI[k] += (Q - (dt < Infinity ? this.portQStart[k - 2] : this.chargeRefs.get(k))) * rate;
3047
+ this.termDI[k] += dQdV * rate;
2684
3048
  }
2685
3049
  }
2686
3050
 
@@ -2801,10 +3165,13 @@ export class Solver {
2801
3165
  this._j(b, 0, b, 0, Ac * link.C * VT);
2802
3166
  B[R[b * M]] += -Ac * link.C;
2803
3167
  this.contactD[side] = sgn * Din;
2804
- if (dyn) {
2805
- I += (Din - Dstart) / dt;
2806
- C[R[b * M]] += (-Ac * link.C * VT) / dt;
2807
- this.termDI[k] += (Ac * link.C) / dt;
3168
+ // Its current, the charging (D_in − D_in at the step's start)/dt; or, steady at no current,
3169
+ // the charge it keeps in place of that (see _captureLaws).
3170
+ const rate = dyn ? 1 / dt : this.steady && this.chargeRefs.has(k) ? 1 : 0;
3171
+ if (rate > 0) {
3172
+ I += (Din - (dyn ? Dstart : this.chargeRefs.get(k))) * rate;
3173
+ C[R[b * M]] += -Ac * link.C * VT * rate;
3174
+ this.termDI[k] += Ac * link.C * rate;
2808
3175
  }
2809
3176
  } else if (link.type === 'pinned' || link.type === 'bulk') {
2810
3177
  // The Poisson residual is the outside's charge, D_in.
@@ -2813,6 +3180,9 @@ export class Solver {
2813
3180
  if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2814
3181
  I += (Din - Dstart) / dt;
2815
3182
  this._captureRow(b, 0, 1 / dt, C);
3183
+ } else if (!dyn && this.steady && this.chargeRefs.has(k)) {
3184
+ I += Din - this.chargeRefs.get(k);
3185
+ this._captureRow(b, 0, 1, C);
2816
3186
  }
2817
3187
  this._replaceRow(b, 0);
2818
3188
  if (link.type === 'pinned') {
@@ -2845,12 +3215,42 @@ export class Solver {
2845
3215
 
2846
3216
  // Steady solves: each flat stretch's level held at its contact's throughout (see
2847
3217
  // flatStretches), in place of its balances, as an equilibrium link holds it at the end node.
3218
+ // A flat stretch's level: its contact's, through the terminal k that holds it, or (reaching
3219
+ // none) its first node's, which its amount fixes (that row is the amount's, a pin whose
3220
+ // response moves the whole stretch: see _applyConstraints).
3221
+ _flatLevel(st) {
3222
+ const { M, u, uLo, z, VT, model } = this, i = st.species;
3223
+ if (st.leftOpen || st.rightOpen) {
3224
+ const side = st.leftOpen ? 'left' : 'right', link = model.contacts[side].species[i], k = side === 'left' ? 0 : 1;
3225
+ return { k, hi: z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT, lo: 0 };
3226
+ }
3227
+ const o = this.blockOfNode[st.nodes[0]] * M + 1 + i;
3228
+ return { k: -1, hi: u[o], lo: uLo[o] };
3229
+ }
3230
+
3231
+ // Each flat stretch's nodes set to its first node's level exactly (where Newton's damped
3232
+ // updates have taken it, on the way to its contact's), so that it carries exactly no flux (the
3233
+ // expm1 form of a zero difference). Flat only to round-off, a level moved by its contact had
3234
+ // read as a current: a MOS capacitor's holes, their contact jumped to −6 V, as
3235
+ // 3e-4 A/m² through a bulk that conducts ~1e10 S/m² (the true current was 3.5e-7).
3236
+ _snapFlat() {
3237
+ const { M, u, uLo } = this;
3238
+ for (const st of this.flatStretches) {
3239
+ const f = this.blockOfNode[st.nodes[0]] * M + 1 + st.species, hi = u[f], lo = uLo[f];
3240
+ for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
3241
+ const o = this.blockOfNode[g] * M + 1 + st.species;
3242
+ if (this.loc[o] < 0) continue;
3243
+ u[o] = hi;
3244
+ uLo[o] = lo;
3245
+ }
3246
+ }
3247
+ }
3248
+
2848
3249
  _flatRows() {
2849
3250
  const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
2850
3251
  for (const st of this.flatStretches) {
2851
- const i = st.species, side = st.leftOpen ? 'left' : 'right', k = side === 'left' ? 0 : 1;
2852
- const link = model.contacts[side].species[i];
2853
- const level = z[i] === 0 ? link.mu / model.RT : (z[i] * (this.termV[k] + link.offset)) / VT;
3252
+ const i = st.species, open = st.leftOpen || st.rightOpen;
3253
+ const { k, hi, lo } = this._flatLevel(st), level = hi + lo;
2854
3254
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
2855
3255
  const b = this.blockOfNode[g], o = b * M + 1 + i;
2856
3256
  if (this.loc[o] < 0) continue;
@@ -2858,7 +3258,7 @@ export class Solver {
2858
3258
  for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
2859
3259
  this._j(b, 1 + i, b, 1 + i, 1);
2860
3260
  res[R[o]] = u[o] + uLo[o] - level;
2861
- if (z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
3261
+ if (open && z[i] !== 0) this.termB[k][R[o]] += -z[i] / VT;
2862
3262
  }
2863
3263
  // A face inside the stretch whose link holds the level continuous carries the species' flux
2864
3264
  // as an unknown that only the edge balances, now replaced, determined: it's zero.
@@ -2875,23 +3275,6 @@ export class Solver {
2875
3275
  }
2876
3276
 
2877
3277
  // Zero one row of the Jacobian (all three blocks), ready to be replaced.
2878
- // Steady solves: each level stretch's levels held equal (see levelStretches), node to node in
2879
- // place of the balances. The first node's row is the amount's (see _applyConstraints).
2880
- _levelRows() {
2881
- const { M, u, uLo, res } = this, R = this.rix;
2882
- for (const st of this.levelStretches) {
2883
- const i = st.species;
2884
- for (let g = st.nodes[0] + 1; g <= st.nodes[1]; g++) {
2885
- const b = this.blockOfNode[g], o = b * M + 1 + i, p = o - M;
2886
- if (this.loc[o] < 0 || this.loc[p] < 0) continue;
2887
- this._replaceRow(b, 1 + i);
2888
- for (const B of this.termB) B[R[o]] = 0;
2889
- this._j(b, 1 + i, b, 1 + i, 1);
2890
- this._j(b, 1 + i, b - 1, 1 + i, -1);
2891
- res[R[o]] = u[o] + uLo[o] - (u[p] + uLo[p]);
2892
- }
2893
- }
2894
- }
2895
3278
  _replaceRow(b, rs) {
2896
3279
  const l = this.loc[b * this.M + rs];
2897
3280
  if (l < 0) return;
@@ -2979,6 +3362,11 @@ export class Solver {
2979
3362
  rest.multiply(v, jv);
2980
3363
  if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
2981
3364
  lin.apply(v, jv, 1, 1 / dt);
3365
+ // A flat stretch's rows hold each level to its first node's (see _flatRows).
3366
+ for (const p of pins) {
3367
+ if (!p.col) continue;
3368
+ for (const o of p.col) if (o !== p.row && this.loc[o] >= 0) jv[R[o]] -= x[R[p.row]];
3369
+ }
2982
3370
  const out = new Float64Array(N + K);
2983
3371
  for (let j = 0; j < N; j++) {
2984
3372
  let t = jv[j];
@@ -3078,7 +3466,7 @@ export class Solver {
3078
3466
  this.islandsOn = islands;
3079
3467
  let rounds = 0, kept = null;
3080
3468
  const islandsOff = (it, result) => {
3081
- if (dt !== Infinity || !this.constrained || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3469
+ if (dt !== Infinity || !this.steady || this.islands.length === 0 || rounds >= 2 || !(this._kirchhoff() > 1e-9)) return false;
3082
3470
  this.islandsOn = true;
3083
3471
  rounds++;
3084
3472
  kept = { it, result, u: Float64Array.from(this.u), uLo: Float64Array.from(this.uLo), termV: Float64Array.from(this.termV) };
@@ -3152,7 +3540,13 @@ export class Solver {
3152
3540
  const alpha = own > maxStep ? maxStep / own : 1;
3153
3541
  this._addToState(delta, -alpha);
3154
3542
  this.floating.forEach((k, a) => (this.termV[k] -= alpha * deltaV[a]));
3155
- if (alpha === 1 && step < tol) {
3543
+ // A time step's Newton contracts fast from its predictor (1e-2, 1e-9, 1e-15 thermal
3544
+ // units). The next update would be about θ = step/(the last one) times this one, so where
3545
+ // θ/(1 − θ) of it (Hairer and Wanner's estimate) is under tol, this update converged it:
3546
+ // the iteration that would only show that is skipped, a third of a typical step's work.
3547
+ const last = history[history.length - 2], theta = step / last;
3548
+ const contracted = dt !== Infinity && it >= 2 && step < 1e-6 && theta < 0.1 && (theta / (1 - theta)) * step < tol;
3549
+ if (alpha === 1 && (step < tol || contracted)) {
3156
3550
  const done = { converged: true, iterations: it, history, residual: rmax };
3157
3551
  if (islandsOff(it, done)) continue;
3158
3552
  this.computeConcentrations();
@@ -3206,18 +3600,20 @@ export class Solver {
3206
3600
  }
3207
3601
  if (!(c.digits > (this.conditioning?.digits ?? -1))) return;
3208
3602
  const { grid } = this.model;
3209
- let x = NaN, where = '';
3603
+ let x = NaN, where = '', nodes = [];
3210
3604
  for (let g = 0; g < this.nNodes; g++) {
3211
3605
  if (this.blockOfNode[g] !== c.block) continue;
3212
3606
  x = grid.x[g];
3213
3607
  where = this.model.regions[grid.nodeRegion[g]].name;
3608
+ nodes = [g];
3214
3609
  }
3215
3610
  for (let f = 0; f < this.nFaces; f++) {
3216
3611
  if (this.blockOfFace[f] !== c.block) continue;
3217
3612
  x = grid.x[grid.regionEnd[f]];
3218
3613
  where = `interfaces[${f}]`;
3614
+ nodes = [grid.regionEnd[f], grid.regionEnd[f] + 1];
3219
3615
  }
3220
- this.conditioning = { digits: c.digits, x, where };
3616
+ this.conditioning = { digits: c.digits, x, where, nodes };
3221
3617
  }
3222
3618
 
3223
3619
  /**
@@ -3307,13 +3703,14 @@ export class Solver {
3307
3703
  // Implicit: sources at the step's end, as seen from within the step (before any jump there).
3308
3704
  // A step landing on a breakpoint ends on it exactly.
3309
3705
  const tEnd = this.landing !== undefined && Math.abs(this.time + dt - this.landing) <= 1e-9 * dt ? this.landing : this.time + dt;
3310
- this.sourceTime = this.steady ? this.time : tEnd;
3706
+ this.sourceTime = this.steady ? this.steadyAt : tEnd;
3311
3707
  this.sourceBefore = !this.steady;
3312
3708
  this.combining = true;
3709
+ this.lawForm = new Map(); // (see _circuitLaws)
3313
3710
  let result;
3314
3711
  try {
3315
3712
  result = this.newton(dtEff, opts);
3316
- if (!result.converged && dtEff === Infinity && this.constrained && this.islands.length > 0) {
3713
+ if (!result.converged && dtEff === Infinity && this.steady && this.islands.length > 0) {
3317
3714
  // Plain solves that don't converge may be lost along an island's level (two regions
3318
3715
  // conducting 1e20 times less hold it): once more from the start, with the islands'
3319
3716
  // summed rows in throughout (see newton).
@@ -3327,6 +3724,20 @@ export class Solver {
3327
3724
  const again = this.newton(dtEff, { ...opts, islands: true });
3328
3725
  result = { ...again, iterations: result.iterations + again.iterations };
3329
3726
  }
3727
+ if (!result.converged && Number.isFinite(dtEff) && [...this.lawForm.values()].some((v) => !v)) {
3728
+ // A terminal read by its flux whose response is lost to round-off (a floating gate's
3729
+ // charging, below its metal's G Δη): once more, read by the change (see _circuitLaws).
3730
+ this.u.set(this.uPrev);
3731
+ this.uLo.set(this.uPrevLo);
3732
+ this.termV.set(this.termVPrev);
3733
+ if (opts.guess) {
3734
+ this.u.set(opts.guess);
3735
+ this.uLo.fill(0);
3736
+ }
3737
+ for (const cs of this.lawForm.keys()) this.lawForm.set(cs, true);
3738
+ const again = this.newton(dtEff, opts);
3739
+ result = { ...again, iterations: result.iterations + again.iterations };
3740
+ }
3330
3741
  } finally {
3331
3742
  this.combining = false;
3332
3743
  }
@@ -3339,11 +3750,16 @@ export class Solver {
3339
3750
  this.contactDOld = this.contactDStart;
3340
3751
  this.portQOld = this.portQStart;
3341
3752
  this._accumulateBoundaryIntake(dtEff, cN);
3753
+ // (In time, the state is no longer the steady solve's that a continuation would start from.)
3754
+ if (!this.steady) {
3755
+ this.solvedV = undefined;
3756
+ this.stepped = true;
3757
+ }
3342
3758
  } else {
3343
3759
  this.u.set(this.uPrev);
3344
3760
  this.uLo.set(this.uPrevLo);
3345
3761
  this.termV.set(this.termVPrev);
3346
- this.sourceTime = this.time;
3762
+ this.sourceTime = this.steady ? this.steadyAt : this.time;
3347
3763
  this.computeConcentrations();
3348
3764
  }
3349
3765
  this.sourceBefore = false;
@@ -3358,6 +3774,11 @@ export class Solver {
3358
3774
  * animation can call it once per frame; the step size carries over between calls.
3359
3775
  */
3360
3776
  integrate(tEnd, opts = {}) {
3777
+ // tEnd is an absolute time. Not a number at all is an error; a NaN (a first animation frame's
3778
+ // clock, or a time read from a field that doesn't exist) does nothing, and says so.
3779
+ 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)`;
3780
+ if (typeof tEnd !== 'number') throw new SolverError(`advance(tEnd): ${absolute}; got ${tEnd}`);
3781
+ 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}` };
3361
3782
  const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
3362
3783
  const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
3363
3784
  const start = clock();
@@ -3562,9 +3983,18 @@ export class Solver {
3562
3983
  if (!(r >= 0 && r < regions.length)) fail(`${path}.region: no region ${JSON.stringify(p.region)}`);
3563
3984
  if (!inside(r)) fail(`${path}.x (${p.x} m) is outside region ${JSON.stringify(p.region)}`);
3564
3985
  } else {
3565
- r = regions.findIndex((_, rr) => inside(rr));
3566
- if (!(Number.isFinite(p.x) && r >= 0)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
3986
+ // At a face, the side where the probe has something to read; both, and it must be told.
3987
+ const has = (rr) => {
3988
+ const mat = this.model.materials[regions[rr].material];
3989
+ return i < 0 ? !mat.conductor && !mat.phiFree : mat.present[i];
3990
+ };
3991
+ const sides = regions.flatMap((_, rr) => (inside(rr) ? [rr] : []));
3992
+ if (!(Number.isFinite(p.x) && sides.length)) fail(`${path}.x must be a position in the device, in m (got ${p.x})`);
3993
+ const able = sides.filter(has);
3994
+ 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`);
3995
+ r = able[0] ?? sides[0];
3567
3996
  }
3997
+ 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`);
3568
3998
  let g = grid.regionStart[r];
3569
3999
  while (g + 1 < grid.regionEnd[r] && x[g + 1] < p.x) g++;
3570
4000
  const w = x[g + 1] > x[g] ? (p.x - x[g]) / (x[g + 1] - x[g]) : 0;
@@ -3718,6 +4148,15 @@ export class Solver {
3718
4148
  if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
3719
4149
  const dT = this.terms[kT].drive;
3720
4150
  if (dT.R > 0) throw new SolverError(`impedance: terminal '${terminal}' has a series resistance; that belongs to the external circuit`);
4151
+ // A small signal returns through the terminals held at a voltage: with every other one driven
4152
+ // by a current (held open), none comes back, and the impedance is infinite (else round-off).
4153
+ const others = this.terms.filter((t, k) => k !== kT);
4154
+ if (others.length && others.every((t) => t.drive.kind === 'I')) {
4155
+ throw new SolverError(
4156
+ `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}': ` +
4157
+ `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`,
4158
+ );
4159
+ }
3721
4160
  const N = nB * M, fl = this.floating, K = fl.length;
3722
4161
  this.sourceTime = this.time;
3723
4162
  this.computeConcentrations();
@@ -4120,6 +4559,72 @@ export class Solver {
4120
4559
  return cuts;
4121
4560
  }
4122
4561
 
4562
+ // Each species' flux toward +x across every segment, at the current state (concentrations
4563
+ // computed): totals through the cross-section, mol/s (mol/(m²·s) for a planar device). A face's
4564
+ // segment, between its pair of nodes, carries the face's flux; a conductor's, its carrier's.
4565
+ // These are the fluxes the balances add up (displacement aside), from the same formulas.
4566
+ segmentFluxes() {
4567
+ const { model, n, M, u, uLo, c, z, ex } = this;
4568
+ const { grid, materials, regions, interfaces } = model;
4569
+ const out = Array.from({ length: n }, () => new Float64Array(grid.nNodes - 1));
4570
+ for (let r = 0; r < regions.length; r++) {
4571
+ const reg = regions[r], mat = materials[reg.material], vel = reg.velocity;
4572
+ for (let s = grid.regionStart[r]; s < grid.regionEnd[r]; s++) {
4573
+ const bL = s + r, bR = bL + 1, h = grid.segLength[s] / grid.segArea[s];
4574
+ if (mat.conductor) {
4575
+ out[mat.conductor.i][s] = u[bL * M];
4576
+ continue;
4577
+ }
4578
+ for (let i = 0; i < n; i++) {
4579
+ if (!mat.present[i] || mat.D[i] === 0) continue;
4580
+ const pe = (vel * h) / mat.D[i];
4581
+ const d = z[i] * (u[bR * M] - u[bL * M]) + ex[(s + 1) * n + i] - ex[s * n + i] - pe;
4582
+ const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]) - pe;
4583
+ out[i][s] = -(mat.D[i] / h) * bernoulli(d) * c[s * n + i] * Math.expm1(deta);
4584
+ }
4585
+ if (reg.mixing > 0) {
4586
+ // Eddy mixing (see _segmentMixing): −(D_mix/h) c̄_i (Δη_i − z_i q), c̄ the logarithmic mean.
4587
+ const on = [...Array(n).keys()].filter((i) => mat.present[i] && mat.D[i] > 0);
4588
+ const cb = [], de = [];
4589
+ let S = 0, Q = 0;
4590
+ for (const k of on) {
4591
+ const a = c[s * n + k], b = c[(s + 1) * n + k];
4592
+ cb[k] = a === b ? a : (b - a) / Math.log(b / a);
4593
+ de[k] = u[bR * M + 1 + k] - u[bL * M + 1 + k] + (uLo[bR * M + 1 + k] - uLo[bL * M + 1 + k]);
4594
+ S += z[k] * z[k] * cb[k];
4595
+ Q += z[k] * cb[k] * de[k];
4596
+ }
4597
+ const q = S > 0 ? Q / S : 0;
4598
+ for (const i of on) out[i][s] -= (reg.mixing / h) * cb[i] * (de[i] - z[i] * q);
4599
+ }
4600
+ }
4601
+ }
4602
+ interfaces.forEach((itf, f) => {
4603
+ const gL = grid.regionEnd[f], b = this.blockOfFace[f];
4604
+ for (let i = 0; i < n; i++) if (itf.links[i].type !== 'blocked') out[i][gL] = grid.area[gL] * u[b * M + 1 + i];
4605
+ });
4606
+ return out;
4607
+ }
4608
+
4609
+ // A face's one-way fluxes for each species crossing it by permeability, per area: toward +x
4610
+ // and toward −x, whose difference is its net flux (Ussing's unidirectional fluxes; GHK's two
4611
+ // terms between like solutions). null for a species crossing any other way.
4612
+ faceOneWay(f) {
4613
+ const { model, n, M, u, uLo, z } = this, itf = model.interfaces[f], grid = model.grid;
4614
+ const bL = this.blockOfFace[f] - 1, bR = bL + 2, gl = grid.regionEnd[f], gr = gl + 1;
4615
+ return itf.links.map((link, i) => {
4616
+ if (link.type !== 'permeability') return null;
4617
+ const kl = gl * n + i, kr = gr * n + i;
4618
+ const shift = this.mu0hat[kr] - this.mu0hat[kl] - Math.log(this.cRef[kr] / this.cRef[kl]);
4619
+ const d = z[i] * (u[bR * M] - u[bL * M] + (uLo[bR * M] - uLo[bL * M])) + shift;
4620
+ const deta = u[bR * M + 1 + i] - u[bL * M + 1 + i] + (uLo[bR * M + 1 + i] - uLo[bL * M + 1 + i]);
4621
+ const gated = link.gates ?? [];
4622
+ const P = link.P * (gated.length ? this._open(gated, (q) => u[this._gateSlot(f, q)]) : 1);
4623
+ const right = P * bernoulli(d) * this.c[kl];
4624
+ return [right, right * Math.exp(deta)];
4625
+ });
4626
+ }
4627
+
4123
4628
  // Complex profiles of δφ (V), δμ̄ (J/mol) and δc (mol/m³) from a small-signal solution.
4124
4629
  _smallSignalProfiles(xr, xi) {
4125
4630
  const { n, M, z, VT, model } = this;
@@ -4203,12 +4708,20 @@ export class Solver {
4203
4708
  if (!Number.isFinite(dt)) return;
4204
4709
  const last = this.model.regions.length - 1;
4205
4710
  const { n, cOld } = this, vol = this.model.grid.vol;
4711
+ // A stretch alone in what a terminal driven by a current feeds takes in what that current
4712
+ // says, which the step kept exactly (see _circuitLaws); the flux through a metal is round-off.
4713
+ const driven = new Map();
4714
+ for (const cs of this.constraints) {
4715
+ if (cs.circuit < 0 || this.terms[cs.circuit].drive.kind !== 'I' || cs.parts.length !== 1 || cs.surface.length || cs.carriers.length) continue;
4716
+ driven.set(cs.parts[0].stretch, (cs.lambda * sourceAt(this.terms[cs.circuit].drive.src, this.sourceTime, this.sourceBefore)) / (cs.parts[0].w * FARADAY));
4717
+ }
4206
4718
  this.stretches.forEach((st, k) => {
4207
4719
  if (!st.connected) return;
4208
4720
  let q = 0;
4209
4721
  if (st.regions[0] === 0) q += this.contactFlux.left[st.species];
4210
4722
  if (st.regions[1] === last) q -= this.contactFlux.right[st.species];
4211
4723
  for (const p of st.ports) q += this.portFlux[p][st.species];
4724
+ if (driven.has(k)) q = driven.get(k);
4212
4725
  let hist = 0;
4213
4726
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) hist += vol[g] * (cOld[g * n + st.species] - cN[g * n + st.species]);
4214
4727
  this.boundaryIntake[k] += q * dt + hist;
@@ -4238,45 +4751,55 @@ export class Solver {
4238
4751
  }
4239
4752
 
4240
4753
  /**
4241
- * Steady state.
4242
- * - If every species stretch is fed by a contact, nothing is conserved on its own, so the
4243
- * true steady equations (dt = ∞, no storage term) are solved directly: no slow modes to
4244
- * wait out, however slow the physics (e.g. exponentially scarce minority carriers).
4245
- * - Otherwise backward-Euler steps at a huge dt, whose storage term pins each conserved
4246
- * amount exactly; dt keeps growing ×10 while the state still moves.
4247
- * If Newton fails, dt ramps up from a small value (pseudo-transient continuation) instead.
4754
+ * Steady state: where a transient from the present state ends, found as the solution of the
4755
+ * steady equations (dt = ∞, no storage term), with no slow modes to wait out, however slow the
4756
+ * physics (e.g. exponentially scarce minority carriers). What the storage term held is held by
4757
+ * the steady system's own rows instead (see _laws): each conserved amount in place of a balance
4758
+ * row that says nothing new, levels that carry nothing held flat, islands' summed balances.
4759
+ * Where Newton from here fails: continuation along the drives, then the pseudo-transient ramp.
4248
4760
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
4249
4761
  */
4250
4762
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
4251
4763
  // a change of drive).
4252
4764
  solveSteady(opts = {}) {
4253
- this.flattening = true;
4254
4765
  this.unreached = null; // (a driven current no held voltage reaches: see _floatingContinuation)
4766
+ this.charging = null; // (a driven current that only charges: see _solveSteadyAll)
4767
+ this._captureLaws();
4768
+ this.steady = true; // (the steady system's rows; sources at the present time)
4769
+ this.steadyAt = this.time; // (which a pseudo-transient's steps don't move on)
4255
4770
  try {
4256
4771
  return this._solveSteadyAll(opts);
4257
4772
  } finally {
4258
- this.flattening = false;
4773
+ this.steady = false;
4259
4774
  }
4260
4775
  }
4261
4776
 
4262
4777
  _solveSteadyAll(opts) {
4263
- // A terminal that passes current only by charging (a gate, a capacitance with no species
4264
- // through it) has no steady state under a current drive: it charges for ever, or at I = 0
4265
- // keeps whatever charge it started with, which a steady solve doesn't know.
4266
- const { species } = this.model, ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
4778
+ // A terminal driven by a current that can only store what comes in (a battery's cathode
4779
+ // behind it, closed but for that terminal; a gate, or a capacitance with no species through
4780
+ // it) has no steady state but at no current, where it keeps what it holds.
4781
+ const { species, regions } = this.model;
4267
4782
  for (const k of this.floating) {
4268
- const t = this.terms[k];
4269
- if (t.drive.kind !== 'I') continue;
4270
- const only = t.kind === 'port' ? (p) => p.reactions.length === 0 && !ions(p.species) : (c) => !ions(c.species);
4271
- if (only(t.kind === 'port' ? this.model.ports[t.index] : this.model.contacts[t.side])) {
4272
- 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`);
4273
- }
4783
+ const I = sourceAt(this.terms[k].drive.src, this.time);
4784
+ if (this.terms[k].drive.kind !== 'I' || I === 0) continue;
4785
+ const laws = this.constraints.filter((cs) => cs.terminals.includes(k));
4786
+ if (laws.length === 0 && !this._chargeOnly(k)) continue;
4787
+ const what = laws.flatMap((cs) => cs.parts.map(({ stretch }) => {
4788
+ const st = this.stretches[stretch], [r0, r1] = st.regions;
4789
+ return `${species[st.species].name} in ${regions[r0].name}${r1 > r0 ? `…${regions[r1].name}` : ''}`;
4790
+ }));
4791
+ this.charging = { terminal: this.terms[k].name, current: I, what };
4792
+ return { converged: false, steps: 0, iterations: 0, history: [] };
4274
4793
  }
4275
4794
  const r = this._steadyFromHere(opts);
4276
4795
  this.atSteady = r.converged;
4277
4796
  return r;
4278
4797
  }
4279
4798
 
4799
+ // Steady state from the present one, in three stages: Newton from here; failing that, from a
4800
+ // solved state along the drives (the light ramped up, a current's terminal held at the voltages
4801
+ // that pass it, the voltages ramped from where they were last solved); failing that, the
4802
+ // pseudo-transient ramp, through time.
4280
4803
  _steadyFromHere(opts) {
4281
4804
  this.conditioning = null;
4282
4805
  this.computeConcentrations();
@@ -4286,86 +4809,110 @@ export class Solver {
4286
4809
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
4287
4810
  const held = (d) => d.kind === 'V' && !(d.R > 0);
4288
4811
  const target = sourceAt(dr.src, this.time), level = sourceAt(dl.src, this.time);
4289
- const canContinue = opts.continuation !== false && held(dl) && held(dr) && target !== level;
4812
+ const allowed = opts.continuation !== false;
4813
+ const canContinue = allowed && held(dl) && held(dr) && target !== level;
4290
4814
  const direct = this._directSteady();
4291
4815
  const u0 = Float64Array.from(this.u), u0Lo = Float64Array.from(this.uLo), v0 = Float64Array.from(this.termV);
4292
- // Where a direct solve applies and continuation is possible, don't spend long on the
4293
- // pseudo-transient ramp: one direct attempt first.
4294
- const quick = (canContinue || this.hasGeneration) && direct && opts.continuation !== false;
4295
- let r = this._solveSteady(quick ? { ...opts, maxSteps: 1 } : opts);
4296
- if (r.converged) this.solvedV = [level, target];
4297
- if (r.converged) return r;
4298
- // Generation (e.g. light) holding the device far from equilibrium: ramp it up from nearly
4299
- // nothing, each solve warm from the last.
4300
- let dimmer = false;
4301
- if (this.hasGeneration && opts.continuation !== false) {
4816
+ const restart = () => {
4302
4817
  this.u.set(u0);
4303
4818
  this.uLo.set(u0Lo);
4304
4819
  this.termV.set(v0);
4305
4820
  this.computeConcentrations();
4821
+ };
4822
+ const solved = (q) => {
4823
+ if (q.converged) {
4824
+ this.solvedV = [level, target];
4825
+ this.stepped = false;
4826
+ }
4827
+ return q;
4828
+ };
4829
+ const after = (q, r) => ({ ...q, steps: q.steps + r.steps, iterations: q.iterations + r.iterations });
4830
+
4831
+ // 1. Newton from here. Where a continuation could follow, one direct attempt only, rather
4832
+ // than the pseudo-transient ramp straight away.
4833
+ const quick = (canContinue || this.hasGeneration) && direct && allowed;
4834
+ let r = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
4835
+ if (r.converged) return solved(r);
4836
+ // A transient's state can be a worse start than a cold one: 1 ns after a lit MOS capacitor's
4837
+ // gate jumps, its surface is in deep depletion, minority carriers ten orders below where they
4838
+ // end, and Newton fails from there where it converges from the layout for the drives (and
4839
+ // the stages below took 9000 iterations from there, 2000 from the layout). What a steady
4840
+ // solve keeps it took from the state already, so from here on it goes as a cold device would.
4841
+ if (this.stepped && direct && allowed) {
4842
+ this._refreshSources();
4843
+ this.initFromComposition();
4844
+ u0.set(this.u);
4845
+ u0Lo.set(this.uLo);
4846
+ const q = this._steadySteps(quick ? { ...opts, maxSteps: 1 } : opts);
4847
+ if (q.converged) return solved(after(q, r));
4848
+ restart();
4849
+ r = after(q, r);
4850
+ }
4851
+
4852
+ // 2. Continuation. Generation (e.g. light) holding the device far from equilibrium: ramp it up
4853
+ // from nearly nothing, each solve warm from the last.
4854
+ let dimmer = false;
4855
+ if (this.hasGeneration && allowed) {
4856
+ restart();
4306
4857
  this.dimmer = false;
4307
4858
  const g = this._generationContinuation(opts, r);
4308
4859
  dimmer = this.dimmer;
4309
- if (g.converged) {
4310
- this.solvedV = [level, target];
4311
- return g;
4312
- }
4860
+ if (g.converged) return solved(g);
4313
4861
  r = g;
4314
4862
  }
4315
4863
  // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
4316
4864
  // voltage until the current crosses its target, and float it from there.
4317
- if (opts.continuation !== false && this.floating.some((k) => this.terms[k].drive.kind === 'I')) {
4318
- this.u.set(u0);
4319
- this.uLo.set(u0Lo);
4320
- this.termV.set(v0);
4321
- this.computeConcentrations();
4865
+ if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
4866
+ restart();
4322
4867
  const q = this._floatingContinuation(opts, r);
4323
4868
  if (q.converged) return q;
4324
4869
  r = q;
4325
4870
  }
4326
- if (!canContinue) {
4327
- if (quick) {
4328
- // The full pseudo-transient ramp: from the dimmer light's solution where the light's
4329
- // continuation got anywhere (a lit floating base charges from there as it would in
4330
- // time), else from the start.
4331
- if (!dimmer) {
4332
- this.u.set(u0);
4333
- this.uLo.set(u0Lo);
4334
- this.termV.set(v0);
4335
- }
4336
- this.computeConcentrations();
4337
- const full = this._solveSteady(opts);
4338
- return { ...full, steps: full.steps + r.steps, iterations: full.iterations + r.iterations };
4339
- }
4340
- return r;
4341
- }
4342
- // Source continuation: solve with both terminals level (consistent with a cold start), then
4343
- // ramp the right terminal's voltage to its target in adaptive steps.
4344
- const restart = () => {
4345
- this.u.set(u0);
4346
- this.uLo.set(u0Lo);
4347
- this.termV.set(v0);
4348
- this.computeConcentrations();
4349
- };
4350
- restart();
4351
- // Ramp from the contacts' voltages at the last converged solve when the state is that
4352
- // solution (a warm start): the left one first where it moved, the right held where it was,
4353
- // then the right. Otherwise from level terminals.
4354
- const solved = this.solvedV;
4355
- let c;
4356
- if (solved) {
4357
- c = solved[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, solved[0], level, r, true, solved[1]);
4358
- // (Ramping the right from level terminals, solve there first: a sweep's start.)
4359
- if (c.converged) c = this._continuation(opts, 1, solved[1], target, c, c.ramped || solved[1] !== level);
4360
- } else c = this._continuation(opts, 1, level, target, r, false);
4361
- if (c.converged) {
4362
- this.solvedV = [level, target];
4363
- return c;
4364
- }
4365
- restart();
4366
- r = this._solveSteady(opts); // the full pseudo-transient ramp
4367
- if (r.converged) this.solvedV = [level, target];
4368
- return { ...r, steps: r.steps + c.steps, iterations: r.iterations + c.iterations };
4871
+ if (canContinue) {
4872
+ // The right terminal's voltage ramped to its target: from the contacts' voltages at the last
4873
+ // converged solve when the state is that solution (a warm start), the left one first where
4874
+ // it moved, the right held where it was; otherwise from level terminals, consistent with a
4875
+ // cold start.
4876
+ restart();
4877
+ const was = this.solvedV;
4878
+ let c;
4879
+ if (was) {
4880
+ c = was[0] === level ? { ...r, converged: true, ramped: false } : this._continuation(opts, 0, was[0], level, r, true, was[1]);
4881
+ // (Ramping the right from level terminals, solve there first: a sweep's start.)
4882
+ if (c.converged) c = this._continuation(opts, 1, was[1], target, c, c.ramped || was[1] !== level);
4883
+ } else c = this._continuation(opts, 1, level, target, r, false);
4884
+ if (c.converged) return solved(c);
4885
+ // 3. The pseudo-transient ramp, from the start.
4886
+ restart();
4887
+ return solved(after(this._steadySteps(opts), c));
4888
+ }
4889
+ // 3. The pseudo-transient ramp (where the first attempt wasn't it already): from the dimmer
4890
+ // light's solution where the light's continuation got anywhere (a lit floating base charges
4891
+ // from there as it would in time), else from the start.
4892
+ if (!quick) return r;
4893
+ if (!dimmer) restart();
4894
+ else this.computeConcentrations();
4895
+ return after(this._steadySteps(opts), r);
4896
+ }
4897
+
4898
+ // Whether terminal k, driven by a current, feeds a combination that it alone can change, or
4899
+ // only charges a capacitance (so that its steady states are one per amount or charge, not one
4900
+ // per voltage).
4901
+ _bound(k) {
4902
+ return this.constraints.some((cs) => cs.terminals.includes(k)) || this._chargeOnly(k);
4903
+ }
4904
+
4905
+ // Whether terminal k passes current only by charging a capacitance: no charged species through
4906
+ // it (nor, a port, reactions), and a capacitance to charge (a gate's φ law, a port's own).
4907
+ _chargeOnly(k) {
4908
+ const t = this.terms[k], { species } = this.model;
4909
+ const ions = (links) => links.some((l, i) => l.type !== 'blocked' && species[i].z !== 0);
4910
+ if (t.kind === 'port') {
4911
+ const p = this.model.ports[t.index];
4912
+ return p.reactions.length === 0 && !ions(p.species) && !!p.capacitance;
4913
+ }
4914
+ const c = this.model.contacts[t.side];
4915
+ return !ions(c.species) && (c.phi.type === 'capacitive' || c.phi.type === 'pinned');
4369
4916
  }
4370
4917
 
4371
4918
  // A cold steady solve with a terminal driven by a current, which can fail where the same state
@@ -4374,7 +4921,7 @@ export class Solver {
4374
4921
  // the start's until the current crosses its target, the crossing narrowed by bisection, and
4375
4922
  // the terminal floated again from beside it.
4376
4923
  _floatingContinuation(opts, r) {
4377
- const k = this.floating.find((j) => this.terms[j].drive.kind === 'I');
4924
+ const k = this.floating.find((j) => this.terms[j].drive.kind === 'I' && !this._bound(j));
4378
4925
  const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
4379
4926
  let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
4380
4927
  const history = (r.history ?? []).slice();
@@ -4437,7 +4984,7 @@ export class Solver {
4437
4984
  this.terms[k].drive = drive;
4438
4985
  this.redrive();
4439
4986
  }
4440
- const q = this._solveSteady(opts);
4987
+ const q = this._steadySteps(opts);
4441
4988
  steps += q.steps ?? 0;
4442
4989
  iterations += q.iterations ?? 0;
4443
4990
  history.push(...(q.history ?? []));
@@ -4480,7 +5027,7 @@ export class Solver {
4480
5027
  try {
4481
5028
  this.generationScale = s;
4482
5029
  // (nearly dark: a cold start can need the full ramp, as a dark device's does)
4483
- let q = this._solveSteady(sub);
5030
+ let q = this._steadySteps(sub);
4484
5031
  steps += q.steps;
4485
5032
  iterations += q.iterations;
4486
5033
  if (!q.converged) return { converged: false, steps, iterations, history };
@@ -4489,7 +5036,7 @@ export class Solver {
4489
5036
  const next = Math.min(1, s * factor);
4490
5037
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4491
5038
  this.generationScale = next;
4492
- q = this._solveSteady(sub);
5039
+ q = this._steadySteps(sub);
4493
5040
  steps += q.steps;
4494
5041
  iterations += q.iterations;
4495
5042
  history.push({ generation: next, converged: q.converged });
@@ -4525,7 +5072,7 @@ export class Solver {
4525
5072
  if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4526
5073
  let q;
4527
5074
  if (!warm) {
4528
- q = this._solveSteady(opts);
5075
+ q = this._steadySteps(opts);
4529
5076
  if (!q.converged) {
4530
5077
  // A cold device's start was laid out for the target (a set() before the first solve
4531
5078
  // builds it there): lay it out again, level.
@@ -4533,7 +5080,7 @@ export class Solver {
4533
5080
  iterations += q.iterations;
4534
5081
  this._refreshSources();
4535
5082
  this.initFromComposition();
4536
- q = this._solveSteady(opts);
5083
+ q = this._steadySteps(opts);
4537
5084
  }
4538
5085
  // (lit, the level start may need the light ramped up too)
4539
5086
  if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
@@ -4545,7 +5092,7 @@ export class Solver {
4545
5092
  const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4546
5093
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4547
5094
  this.sourceOverride.set(k, next);
4548
- q = this._solveSteady(sub);
5095
+ q = this._steadySteps(sub);
4549
5096
  steps += q.steps;
4550
5097
  iterations += q.iterations;
4551
5098
  history.push({ continuation: next, converged: q.converged });
@@ -4569,23 +5116,11 @@ export class Solver {
4569
5116
  }
4570
5117
  }
4571
5118
 
4572
- _solveSteady(opts = {}) {
4573
- this.constrained = true; // spectators' amounts as constraints in the dt = ∞ solves
4574
- this.steady = true;
4575
- try {
4576
- return this._steadySteps(opts);
4577
- } finally {
4578
- this.constrained = false;
4579
- this.steady = false;
4580
- }
4581
- }
4582
-
4583
5119
  _steadySteps({ maxSteps = 80, tol = 1e-11 } = {}) {
4584
5120
  const time = this.time;
4585
5121
  const tau = this.slowestTime();
4586
5122
  const direct = this._directSteady();
4587
5123
  const giant = direct ? Infinity : 1e6 * tau;
4588
- if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
4589
5124
  let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
4590
5125
  let triedDirect = 0;
4591
5126
  let totalIter = 0, steps = 0, converged = false;