driftlet 0.14.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
  }
@@ -2536,7 +2881,8 @@ export class Solver {
2536
2881
  const b = this.blockOfNode[g], o = b * M + r, v = vol[g];
2537
2882
  const deta = target - (u[o] + uLo[o]); // (μ̄_out − μ̄)/RT
2538
2883
  if (link.type === 'equilibrium') {
2539
- 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;
2540
2886
  flux[i] += res[R[o]];
2541
2887
  this._captureRow(b, r, zF, C);
2542
2888
  this._replaceRow(b, r);
@@ -2678,6 +3024,9 @@ export class Solver {
2678
3024
  const { M, u, uLo, res, VT } = this, R = this.rix, vol = this.model.grid.vol, area = this.portArea[k - 2];
2679
3025
  const { C: Cs, zeroCharge } = port.capacitance, V = this.termV[k], B = this.termB[k], Ct = this.termC[k];
2680
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;
2681
3030
  let Q = 0, dQdV = 0;
2682
3031
  port.nodes.forEach((g, w) => {
2683
3032
  if ((g === 0 && sets('left')) || (g === this.nNodes - 1 && sets('right')) || this.phiUndefined[g]) return;
@@ -2690,12 +3039,12 @@ export class Solver {
2690
3039
  res[R[b * M]] -= q;
2691
3040
  this._j(b, 0, b, 0, s * VT);
2692
3041
  B[R[b * M]] -= s;
2693
- if (Number.isFinite(dt)) Ct[R[b * M]] += (-s * VT) / dt;
3042
+ if (rate > 0) Ct[R[b * M]] += -s * VT * rate;
2694
3043
  });
2695
3044
  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;
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;
2699
3048
  }
2700
3049
  }
2701
3050
 
@@ -2816,10 +3165,13 @@ export class Solver {
2816
3165
  this._j(b, 0, b, 0, Ac * link.C * VT);
2817
3166
  B[R[b * M]] += -Ac * link.C;
2818
3167
  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;
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;
2823
3175
  }
2824
3176
  } else if (link.type === 'pinned' || link.type === 'bulk') {
2825
3177
  // The Poisson residual is the outside's charge, D_in.
@@ -2828,6 +3180,9 @@ export class Solver {
2828
3180
  if (dyn && !(this.combining && this.lateStorage[g] === 1)) {
2829
3181
  I += (Din - Dstart) / dt;
2830
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);
2831
3186
  }
2832
3187
  this._replaceRow(b, 0);
2833
3188
  if (link.type === 'pinned') {
@@ -2860,12 +3215,42 @@ export class Solver {
2860
3215
 
2861
3216
  // Steady solves: each flat stretch's level held at its contact's throughout (see
2862
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
+
2863
3249
  _flatRows() {
2864
3250
  const { M, u, uLo, res, z, VT, model } = this, R = this.rix;
2865
3251
  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;
3252
+ const i = st.species, open = st.leftOpen || st.rightOpen;
3253
+ const { k, hi, lo } = this._flatLevel(st), level = hi + lo;
2869
3254
  for (let g = st.nodes[0]; g <= st.nodes[1]; g++) {
2870
3255
  const b = this.blockOfNode[g], o = b * M + 1 + i;
2871
3256
  if (this.loc[o] < 0) continue;
@@ -2873,7 +3258,7 @@ export class Solver {
2873
3258
  for (const B of this.termB) B[R[o]] = 0; // (the row's old terminal terms: the end node's own link)
2874
3259
  this._j(b, 1 + i, b, 1 + i, 1);
2875
3260
  res[R[o]] = u[o] + uLo[o] - level;
2876
- 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;
2877
3262
  }
2878
3263
  // A face inside the stretch whose link holds the level continuous carries the species' flux
2879
3264
  // as an unknown that only the edge balances, now replaced, determined: it's zero.
@@ -2890,23 +3275,6 @@ export class Solver {
2890
3275
  }
2891
3276
 
2892
3277
  // 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]);
2907
- }
2908
- }
2909
- }
2910
3278
  _replaceRow(b, rs) {
2911
3279
  const l = this.loc[b * this.M + rs];
2912
3280
  if (l < 0) return;
@@ -2994,6 +3362,11 @@ export class Solver {
2994
3362
  rest.multiply(v, jv);
2995
3363
  if (save.transformed) this._untransform(v); // (the dilute terms in the plain unknowns)
2996
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
+ }
2997
3370
  const out = new Float64Array(N + K);
2998
3371
  for (let j = 0; j < N; j++) {
2999
3372
  let t = jv[j];
@@ -3093,7 +3466,7 @@ export class Solver {
3093
3466
  this.islandsOn = islands;
3094
3467
  let rounds = 0, kept = null;
3095
3468
  const islandsOff = (it, result) => {
3096
- 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;
3097
3470
  this.islandsOn = true;
3098
3471
  rounds++;
3099
3472
  kept = { it, result, u: Float64Array.from(this.u), uLo: Float64Array.from(this.uLo), termV: Float64Array.from(this.termV) };
@@ -3227,18 +3600,20 @@ export class Solver {
3227
3600
  }
3228
3601
  if (!(c.digits > (this.conditioning?.digits ?? -1))) return;
3229
3602
  const { grid } = this.model;
3230
- let x = NaN, where = '';
3603
+ let x = NaN, where = '', nodes = [];
3231
3604
  for (let g = 0; g < this.nNodes; g++) {
3232
3605
  if (this.blockOfNode[g] !== c.block) continue;
3233
3606
  x = grid.x[g];
3234
3607
  where = this.model.regions[grid.nodeRegion[g]].name;
3608
+ nodes = [g];
3235
3609
  }
3236
3610
  for (let f = 0; f < this.nFaces; f++) {
3237
3611
  if (this.blockOfFace[f] !== c.block) continue;
3238
3612
  x = grid.x[grid.regionEnd[f]];
3239
3613
  where = `interfaces[${f}]`;
3614
+ nodes = [grid.regionEnd[f], grid.regionEnd[f] + 1];
3240
3615
  }
3241
- this.conditioning = { digits: c.digits, x, where };
3616
+ this.conditioning = { digits: c.digits, x, where, nodes };
3242
3617
  }
3243
3618
 
3244
3619
  /**
@@ -3328,13 +3703,14 @@ export class Solver {
3328
3703
  // Implicit: sources at the step's end, as seen from within the step (before any jump there).
3329
3704
  // A step landing on a breakpoint ends on it exactly.
3330
3705
  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;
3706
+ this.sourceTime = this.steady ? this.steadyAt : tEnd;
3332
3707
  this.sourceBefore = !this.steady;
3333
3708
  this.combining = true;
3709
+ this.lawForm = new Map(); // (see _circuitLaws)
3334
3710
  let result;
3335
3711
  try {
3336
3712
  result = this.newton(dtEff, opts);
3337
- if (!result.converged && dtEff === Infinity && this.constrained && this.islands.length > 0) {
3713
+ if (!result.converged && dtEff === Infinity && this.steady && this.islands.length > 0) {
3338
3714
  // Plain solves that don't converge may be lost along an island's level (two regions
3339
3715
  // conducting 1e20 times less hold it): once more from the start, with the islands'
3340
3716
  // summed rows in throughout (see newton).
@@ -3348,6 +3724,20 @@ export class Solver {
3348
3724
  const again = this.newton(dtEff, { ...opts, islands: true });
3349
3725
  result = { ...again, iterations: result.iterations + again.iterations };
3350
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
+ }
3351
3741
  } finally {
3352
3742
  this.combining = false;
3353
3743
  }
@@ -3360,11 +3750,16 @@ export class Solver {
3360
3750
  this.contactDOld = this.contactDStart;
3361
3751
  this.portQOld = this.portQStart;
3362
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
+ }
3363
3758
  } else {
3364
3759
  this.u.set(this.uPrev);
3365
3760
  this.uLo.set(this.uPrevLo);
3366
3761
  this.termV.set(this.termVPrev);
3367
- this.sourceTime = this.time;
3762
+ this.sourceTime = this.steady ? this.steadyAt : this.time;
3368
3763
  this.computeConcentrations();
3369
3764
  }
3370
3765
  this.sourceBefore = false;
@@ -3379,6 +3774,11 @@ export class Solver {
3379
3774
  * animation can call it once per frame; the step size carries over between calls.
3380
3775
  */
3381
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}` };
3382
3782
  const { tol = 1e-3, dtMax = Infinity, budgetMs = Infinity, maxSteps = 100000, method = 'bdf2' } = opts;
3383
3783
  const clock = () => (globalThis.performance ? globalThis.performance.now() : Date.now());
3384
3784
  const start = clock();
@@ -3583,9 +3983,18 @@ export class Solver {
3583
3983
  if (!(r >= 0 && r < regions.length)) fail(`${path}.region: no region ${JSON.stringify(p.region)}`);
3584
3984
  if (!inside(r)) fail(`${path}.x (${p.x} m) is outside region ${JSON.stringify(p.region)}`);
3585
3985
  } 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})`);
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];
3588
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`);
3589
3998
  let g = grid.regionStart[r];
3590
3999
  while (g + 1 < grid.regionEnd[r] && x[g + 1] < p.x) g++;
3591
4000
  const w = x[g + 1] > x[g] ? (p.x - x[g]) / (x[g + 1] - x[g]) : 0;
@@ -3739,6 +4148,15 @@ export class Solver {
3739
4148
  if (kT < 0) throw new SolverError(`impedance: no terminal named '${terminal}' (${this.terms.map((t) => t.name).join(', ')})`);
3740
4149
  const dT = this.terms[kT].drive;
3741
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
+ }
3742
4160
  const N = nB * M, fl = this.floating, K = fl.length;
3743
4161
  this.sourceTime = this.time;
3744
4162
  this.computeConcentrations();
@@ -4290,12 +4708,20 @@ export class Solver {
4290
4708
  if (!Number.isFinite(dt)) return;
4291
4709
  const last = this.model.regions.length - 1;
4292
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
+ }
4293
4718
  this.stretches.forEach((st, k) => {
4294
4719
  if (!st.connected) return;
4295
4720
  let q = 0;
4296
4721
  if (st.regions[0] === 0) q += this.contactFlux.left[st.species];
4297
4722
  if (st.regions[1] === last) q -= this.contactFlux.right[st.species];
4298
4723
  for (const p of st.ports) q += this.portFlux[p][st.species];
4724
+ if (driven.has(k)) q = driven.get(k);
4299
4725
  let hist = 0;
4300
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]);
4301
4727
  this.boundaryIntake[k] += q * dt + hist;
@@ -4325,45 +4751,55 @@ export class Solver {
4325
4751
  }
4326
4752
 
4327
4753
  /**
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.
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.
4335
4760
  * The clock is not advanced, and open-system conservation bookkeeping restarts here.
4336
4761
  */
4337
4762
  // Steady state from the present one; `atSteady` says whether the state is one (until a step or
4338
4763
  // a change of drive).
4339
4764
  solveSteady(opts = {}) {
4340
- this.flattening = true;
4341
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)
4342
4770
  try {
4343
4771
  return this._solveSteadyAll(opts);
4344
4772
  } finally {
4345
- this.flattening = false;
4773
+ this.steady = false;
4346
4774
  }
4347
4775
  }
4348
4776
 
4349
4777
  _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);
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;
4354
4782
  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
- }
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: [] };
4361
4793
  }
4362
4794
  const r = this._steadyFromHere(opts);
4363
4795
  this.atSteady = r.converged;
4364
4796
  return r;
4365
4797
  }
4366
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.
4367
4803
  _steadyFromHere(opts) {
4368
4804
  this.conditioning = null;
4369
4805
  this.computeConcentrations();
@@ -4373,86 +4809,110 @@ export class Solver {
4373
4809
  const [dl, dr] = [this.terms[0].drive, this.terms[1].drive];
4374
4810
  const held = (d) => d.kind === 'V' && !(d.R > 0);
4375
4811
  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;
4812
+ const allowed = opts.continuation !== false;
4813
+ const canContinue = allowed && held(dl) && held(dr) && target !== level;
4377
4814
  const direct = this._directSteady();
4378
4815
  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) {
4816
+ const restart = () => {
4389
4817
  this.u.set(u0);
4390
4818
  this.uLo.set(u0Lo);
4391
4819
  this.termV.set(v0);
4392
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();
4393
4857
  this.dimmer = false;
4394
4858
  const g = this._generationContinuation(opts, r);
4395
4859
  dimmer = this.dimmer;
4396
- if (g.converged) {
4397
- this.solvedV = [level, target];
4398
- return g;
4399
- }
4860
+ if (g.converged) return solved(g);
4400
4861
  r = g;
4401
4862
  }
4402
4863
  // A terminal driven by a current (open circuit, say): hold it at a voltage instead, march the
4403
4864
  // 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();
4865
+ if (allowed && this.floating.some((k) => this.terms[k].drive.kind === 'I' && !this._bound(k))) {
4866
+ restart();
4409
4867
  const q = this._floatingContinuation(opts, r);
4410
4868
  if (q.converged) return q;
4411
4869
  r = q;
4412
4870
  }
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 };
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');
4456
4916
  }
4457
4917
 
4458
4918
  // A cold steady solve with a terminal driven by a current, which can fail where the same state
@@ -4461,7 +4921,7 @@ export class Solver {
4461
4921
  // the start's until the current crosses its target, the crossing narrowed by bisection, and
4462
4922
  // the terminal floated again from beside it.
4463
4923
  _floatingContinuation(opts, r) {
4464
- 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));
4465
4925
  const drive = this.terms[k].drive, target = sourceAt(drive.src, this.time);
4466
4926
  let steps = r.steps ?? 0, iterations = r.iterations ?? 0;
4467
4927
  const history = (r.history ?? []).slice();
@@ -4524,7 +4984,7 @@ export class Solver {
4524
4984
  this.terms[k].drive = drive;
4525
4985
  this.redrive();
4526
4986
  }
4527
- const q = this._solveSteady(opts);
4987
+ const q = this._steadySteps(opts);
4528
4988
  steps += q.steps ?? 0;
4529
4989
  iterations += q.iterations ?? 0;
4530
4990
  history.push(...(q.history ?? []));
@@ -4567,7 +5027,7 @@ export class Solver {
4567
5027
  try {
4568
5028
  this.generationScale = s;
4569
5029
  // (nearly dark: a cold start can need the full ramp, as a dark device's does)
4570
- let q = this._solveSteady(sub);
5030
+ let q = this._steadySteps(sub);
4571
5031
  steps += q.steps;
4572
5032
  iterations += q.iterations;
4573
5033
  if (!q.converged) return { converged: false, steps, iterations, history };
@@ -4576,7 +5036,7 @@ export class Solver {
4576
5036
  const next = Math.min(1, s * factor);
4577
5037
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4578
5038
  this.generationScale = next;
4579
- q = this._solveSteady(sub);
5039
+ q = this._steadySteps(sub);
4580
5040
  steps += q.steps;
4581
5041
  iterations += q.iterations;
4582
5042
  history.push({ generation: next, converged: q.converged });
@@ -4612,7 +5072,7 @@ export class Solver {
4612
5072
  if (hold !== undefined) this.sourceOverride.set(1 - k, hold);
4613
5073
  let q;
4614
5074
  if (!warm) {
4615
- q = this._solveSteady(opts);
5075
+ q = this._steadySteps(opts);
4616
5076
  if (!q.converged) {
4617
5077
  // A cold device's start was laid out for the target (a set() before the first solve
4618
5078
  // builds it there): lay it out again, level.
@@ -4620,7 +5080,7 @@ export class Solver {
4620
5080
  iterations += q.iterations;
4621
5081
  this._refreshSources();
4622
5082
  this.initFromComposition();
4623
- q = this._solveSteady(opts);
5083
+ q = this._steadySteps(opts);
4624
5084
  }
4625
5085
  // (lit, the level start may need the light ramped up too)
4626
5086
  if (!q.converged && this.hasGeneration) q = this._generationContinuation(opts, q);
@@ -4632,7 +5092,7 @@ export class Solver {
4632
5092
  const next = Math.abs(target - V) <= Math.abs(dV) ? target : V + dV;
4633
5093
  const u1 = Float64Array.from(this.u), u1Lo = Float64Array.from(this.uLo), v1 = Float64Array.from(this.termV);
4634
5094
  this.sourceOverride.set(k, next);
4635
- q = this._solveSteady(sub);
5095
+ q = this._steadySteps(sub);
4636
5096
  steps += q.steps;
4637
5097
  iterations += q.iterations;
4638
5098
  history.push({ continuation: next, converged: q.converged });
@@ -4656,23 +5116,11 @@ export class Solver {
4656
5116
  }
4657
5117
  }
4658
5118
 
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
5119
  _steadySteps({ maxSteps = 80, tol = 1e-11 } = {}) {
4671
5120
  const time = this.time;
4672
5121
  const tau = this.slowestTime();
4673
5122
  const direct = this._directSteady();
4674
5123
  const giant = direct ? Infinity : 1e6 * tau;
4675
- if (direct) this._renormalizeSpectators(); // a starting point with the right amounts
4676
5124
  let dt = giant, grow = 10; // (after a failure, dt grows by less, then back up to ×10)
4677
5125
  let triedDirect = 0;
4678
5126
  let totalIter = 0, steps = 0, converged = false;